diff --git a/Compilation/EmittedRuntime.cs b/Compilation/EmittedRuntime.cs
index 66930675..9276bb30 100644
--- a/Compilation/EmittedRuntime.cs
+++ b/Compilation/EmittedRuntime.cs
@@ -80,6 +80,18 @@ public class EmittedRuntime
public FieldBuilder CancelRequestedField { get; set; } = null!;
public MethodBuilder CheckCancellationMethod { get; set; } = null!;
+ // Loop-backedge cancellation throws via `call BuildCancellationException();
+ // throw` rather than `call CheckCancellation()`. CheckCancellation() is a
+ // *returning* call from the JIT's flow-graph view (its throw is internal and
+ // conditional), so on SysV x64 — where every XMM register is caller-saved —
+ // it forces loop-carried doubles to be stack-resident across every iteration.
+ // A `throw` does not return, so the values are dead on the cancel path and
+ // stay in registers. Measured ~1.8× on tight numeric loops (#856). This
+ // factory only *constructs* the exception (no throw), so the backedge emits a
+ // genuine `throw` opcode. CheckCancellationMethod is retained for the
+ // non-hot-loop call sites (event loop, deep-recursion guard).
+ public MethodBuilder BuildCancellationExceptionMethod { get; set; } = null!;
+
// The emitted runtime helper class
public TypeBuilder RuntimeType { get; set; } = null!;
diff --git a/Compilation/RuntimeEmitter.RuntimeClass.cs b/Compilation/RuntimeEmitter.RuntimeClass.cs
index 4bc3c890..2dcb4d13 100644
--- a/Compilation/RuntimeEmitter.RuntimeClass.cs
+++ b/Compilation/RuntimeEmitter.RuntimeClass.cs
@@ -245,6 +245,27 @@ FieldBuilder DefineSubclassProto(string fieldName) =>
il.Emit(OpCodes.Ret);
}
+ // BuildCancellationException(): constructs and RETURNS (does not throw)
+ // the OperationCanceledException used at loop backedges. Loop emitters
+ // emit `call BuildCancellationException(); throw` so the cancel path is a
+ // non-returning `throw` rather than a returning `call CheckCancellation()`
+ // — keeping the hot loop body free of a call that would otherwise force
+ // loop-carried doubles onto the stack on SysV x64 (~1.8× on tight numeric
+ // loops, #856). See EmittedRuntime.BuildCancellationExceptionMethod.
+ var buildCancelEx = typeBuilder.DefineMethod(
+ "BuildCancellationException",
+ MethodAttributes.Public | MethodAttributes.Static,
+ typeof(Exception),
+ Type.EmptyTypes);
+ runtime.BuildCancellationExceptionMethod = buildCancelEx;
+ {
+ var il = buildCancelEx.GetILGenerator();
+ il.Emit(OpCodes.Ldstr, "Compiled execution cancelled.");
+ il.Emit(OpCodes.Newobj,
+ typeof(OperationCanceledException).GetConstructor([typeof(string)])!);
+ il.Emit(OpCodes.Ret);
+ }
+
// Static field for Random
var randomField = typeBuilder.DefineField("_random", _types.Random, FieldAttributes.Private | FieldAttributes.Static);
diff --git a/Compilation/StatementEmitterBase.cs b/Compilation/StatementEmitterBase.cs
index b7813784..be18fb1e 100644
--- a/Compilation/StatementEmitterBase.cs
+++ b/Compilation/StatementEmitterBase.cs
@@ -372,12 +372,21 @@ protected virtual void EmitIf(Stmt.If i)
/// runner's timeout.
///
///
- /// Perf (#856): instead of an unconditional call $Runtime.CheckCancellation(),
- /// we inline the field test and only call the (throwing) helper on the cold
- /// cancel path. RyuJIT will not inline CheckCancellation itself (it
- /// contains newobj+throw), so the bare call sat in every loop
- /// body as a per-iteration optimization barrier — measured at ~half the
- /// runtime of a tight numeric loop. Inlining the test recovers ~1.6×.
+ /// Perf (#856): the backedge inlines the field test and, on the cold cancel
+ /// path, emits call BuildCancellationException(); throw — NOT
+ /// call CheckCancellation(). The distinction is decisive on SysV x64:
+ /// CheckCancellation() is, from the JIT's flow-graph view, a
+ /// returning call (its throw is internal and conditional), so
+ /// the register allocator must assume control returns from it. Because every
+ /// XMM register is caller-saved on SysV x64, a returning call inside the loop
+ /// forces the loop-carried doubles (and the loop counter) to be stack-resident
+ /// across every iteration — a load/store per use. Emitting a real throw
+ /// opcode makes the path non-returning, so those values are dead on the cancel
+ /// path and stay in registers on the hot path. Measured ~1.8× on tight numeric
+ /// loops (objects/factorial reach Node parity); the earlier inline-call form
+ /// (#874) only removed the unconditional-call overhead, not this spill.
+ /// BuildCancellationException merely constructs the exception
+ /// (it does not throw), so the throw happens here at the backedge.
///
/// The volatile. prefix is mandatory: _cancelRequested is
/// loop-invariant, so a plain ldsfld could be hoisted out of the loop
@@ -387,14 +396,15 @@ protected virtual void EmitIf(Stmt.If i)
///
protected void EmitCancellationCheck()
{
- if (Ctx.Runtime?.CheckCancellationMethod == null || Ctx.Runtime?.CancelRequestedField == null)
+ if (Ctx.Runtime?.BuildCancellationExceptionMethod == null || Ctx.Runtime?.CancelRequestedField == null)
return;
var notCancelled = IL.DefineLabel();
IL.Emit(OpCodes.Volatile);
IL.Emit(OpCodes.Ldsfld, Ctx.Runtime.CancelRequestedField);
IL.Emit(OpCodes.Brfalse, notCancelled);
- IL.Emit(OpCodes.Call, Ctx.Runtime.CheckCancellationMethod);
+ IL.Emit(OpCodes.Call, Ctx.Runtime.BuildCancellationExceptionMethod);
+ IL.Emit(OpCodes.Throw);
IL.MarkLabel(notCancelled);
}
diff --git a/STATUS.md b/STATUS.md
index 496da4ac..2e13bd68 100644
--- a/STATUS.md
+++ b/STATUS.md
@@ -2,7 +2,7 @@
This document tracks TypeScript language features and their implementation status in SharpTS.
-**Last Updated:** 2026-06-20 (Perf epic [#856](https://github.com/nickna/SharpTS/issues/856) — compiled output now meets or beats Node.js on most of the cross-runtime benchmark suite; loop-backedge cancellation check inlined, [#874](https://github.com/nickna/SharpTS/pull/874))
+**Last Updated:** 2026-06-21 (Perf epic [#856](https://github.com/nickna/SharpTS/issues/856) — compiled output now meets or beats Node.js on 5 of 7 cross-runtime workloads, the other two within ~1.2×; loop-backedge cancellation now emits `throw` instead of a returning `call`, recovering ~1.8× on tight numeric loops — see §18)
## Legend
- ✅ Implemented
@@ -518,19 +518,21 @@ Epic [#856](https://github.com/nickna/SharpTS/issues/856) tracks closing the com
| Workload | Status | vs Node |
|---|---|---|
-| fibonacci | ✅ | **faster than Node** — recursion/call core |
-| array-methods | ✅ | **faster than Node** — typed `List` HOF pipeline ([#872](https://github.com/nickna/SharpTS/issues/872)) |
-| strings | ✅ | ≈ parity — `StringBuilder` accumulator promotion ([#870](https://github.com/nickna/SharpTS/issues/870)) + `charCodeAt` box-elision ([#873](https://github.com/nickna/SharpTS/issues/873)) |
-| closures | ✅ | done — non-escaping local arrows de-virtualized to direct calls ([#858](https://github.com/nickna/SharpTS/issues/858)) |
-| objects | ✅ | done — object literals as shape structs ([#862](https://github.com/nickna/SharpTS/issues/862)) |
-| count-primes | ⚠️ | ~1.3× slower (sieve; array-heavy loop) |
-| factorial | ⚠️ | ~3× slower (tight numeric loop; µs-scale at benchmark sizes) |
+| fibonacci | ✅ | **~2.4× faster** — recursion/call core |
+| array-methods | ✅ | **~2× faster** — typed `List` HOF pipeline ([#872](https://github.com/nickna/SharpTS/issues/872)) |
+| strings | ✅ | **faster** (~0.9×) — `StringBuilder` accumulator promotion ([#870](https://github.com/nickna/SharpTS/issues/870)) + `charCodeAt` box-elision ([#873](https://github.com/nickna/SharpTS/issues/873)) |
+| objects | ✅ | **parity** (1.00×) — object literals as shape structs ([#862](https://github.com/nickna/SharpTS/issues/862)) + cancel-throw codegen (below) |
+| closures | ✅ | **parity** (~1.02×) — non-escaping local arrows de-virtualized to direct calls ([#858](https://github.com/nickna/SharpTS/issues/858)) |
+| count-primes | ✅ | ~1.13× (sieve; `List` index-write bounds checks are the residual) |
+| factorial | ✅ | ~1.22× (tight numeric loop at the codegen floor; V8 is ~0.2 ns/iter tighter; µs-scale) |
-The original catastrophic gaps (14–117× slower) are closed. Every win came from **re-exposing static types that the naive lowering erased** — boxing, `object`/`List