From c051a728befceffd72727de07f040b4af8cb969e Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Mon, 13 Jul 2026 15:35:28 +0300 Subject: [PATCH 1/4] Amortize observation-only constraint checks to every 64 statements With any constraint registered (a TimeoutInterval is a very common embedder configuration), the interpreter paid a virtual Check() call per executed statement: EvaluationContext.RunBeforeExecuteStatementChecks looped the full Constraint[] every time. ETW profiling showed this at 2.6% self time with a single TimeConstraint registered. Partition the constraint set once at Engine construction (the set is fixed for the engine's lifetime) into: - amortized: the built-in TimeConstraint, CancellationConstraint and MemoryLimitConstraint. These only observe external state (a timer, a token, an allocation counter), so checking them every N statements is semantically equivalent to checking per statement with bounded detection latency - the same reasoning bulk built-ins already apply via Engine.ConstraintCheckInterval. All three types are sealed, so the type tests cannot match user-derived subclasses. - exact: MaxStatementsConstraint (counting statements is its semantics) and any user-derived Constraint (changing call frequency would be a silent breaking behavior change). These keep running before every statement, as does the debugger step hook. Per statement, the amortized slice is driven by a countdown field on EvaluationContext (per-context state, no thread-safety concern) with N = 64: with only amortized constraints registered - the common timeout-only setup - the per-statement cost collapses to a countdown decrement and branch. Engine.RunBeforeExecuteStatementChecks keeps checking the full set for its remaining native-callback call site (sort comparers), where the callee may not run any JS statements itself. Tests: user-derived constraints are proven to still get Check() exactly once per executed statement (3- and 4-statement scripts assert exact counts), MaxStatements and user constraints stay precise inside function-local loops, and a timeout still fires inside an infinite function-local for loop. Co-Authored-By: Claude Fable 5 --- .../Runtime/ExecutionConstraintTests.cs | 69 +++++++++++++++++++ Jint/Engine.Constraints.cs | 40 +++++++++++ Jint/Engine.cs | 46 +++++++++++++ Jint/Runtime/Interpreter/EvaluationContext.cs | 45 ++++++++++-- 4 files changed, 194 insertions(+), 6 deletions(-) diff --git a/Jint.Tests/Runtime/ExecutionConstraintTests.cs b/Jint.Tests/Runtime/ExecutionConstraintTests.cs index b22910dfeb..037ff773a3 100644 --- a/Jint.Tests/Runtime/ExecutionConstraintTests.cs +++ b/Jint.Tests/Runtime/ExecutionConstraintTests.cs @@ -28,6 +28,63 @@ public void ShouldCountStatementsPrecisely() ); } + [Fact] + public void ShouldCountStatementsInsideFunctionLocalLoop() + { + // The function-local expression-body for loop is the shape that arms the interpreter's + // tight for-body lane; MaxStatements counts statements (its call frequency IS its + // semantics), so it must keep the loop on the per-statement path and trip precisely. + var engine = new Engine(cfg => cfg.MaxStatements(1_000)); + Assert.Throws( + () => engine.Evaluate("function f() { var x = 0; for (var i = 0; i < 100000; i++) { x += 1; } return x; } f();") + ); + } + + [Fact] + public void UserDefinedConstraintIsCheckedOncePerStatement() + { + // Per-statement call frequency is part of the Constraint contract for user-derived + // implementations; amortizing them would be a silent behavior break. Expected counts: + // JintStatement.Execute checks once per non-block statement; the top-level program list + // itself does not check (JintStatementList only checks when it represents a function + // body or block statement node). + var constraint = new CountingConstraint(); + var engine = new Engine(cfg => cfg.Constraint(constraint)); + + engine.Evaluate("var x = 0; x++; x + 5"); + Assert.Equal(3, constraint.CheckCount); + + // one more top-level statement -> exactly one more check + constraint.ResetCount(); + engine.Evaluate("var x = 0; x++; x++; x + 5"); + Assert.Equal(4, constraint.CheckCount); + } + + [Fact] + public void UserDefinedConstraintIsCheckedPerStatementInsideFunctionLocalLoop() + { + // A user-derived constraint must also keep the tight for-body lane disarmed: every body + // statement of every iteration goes through the per-statement checks. + var constraint = new CountingConstraint(); + var engine = new Engine(cfg => cfg.Constraint(constraint)); + + engine.Evaluate("function f() { var x = 0; for (var i = 0; i < 1000; i++) { x += 1; } return x; } f();"); + Assert.True(constraint.CheckCount >= 1000, $"expected at least one check per loop-body statement, got {constraint.CheckCount}"); + } + + private sealed class CountingConstraint : Constraint + { + public int CheckCount { get; private set; } + + public void ResetCount() => CheckCount = 0; + + public override void Check() => CheckCount++; + + public override void Reset() + { + } + } + [Fact] public void ShouldThrowMemoryLimitExceeded() { @@ -44,6 +101,18 @@ public void ShouldThrowTimeout() ); } + [Fact] + public void ShouldThrowTimeoutInsideFunctionLocalTightLoop() + { + // The function-local expression-body for loop is the shape that arms the interpreter's + // tight for-body lane; a timeout is amortized (checked every N statements/iterations), + // and must still fire inside the loop. + var engine = new Engine(cfg => cfg.TimeoutInterval(new TimeSpan(0, 0, 0, 0, 500))); + Assert.Throws( + () => engine.Evaluate("function f() { var x = 0; for (var i = 0; i < 1; i += 0) { x += 1; } return x; } f();") + ); + } + [Fact] public void ShouldThrowExecutionCanceled() { diff --git a/Jint/Engine.Constraints.cs b/Jint/Engine.Constraints.cs index 47df6eb2c4..fd6143f9f1 100644 --- a/Jint/Engine.Constraints.cs +++ b/Jint/Engine.Constraints.cs @@ -1,9 +1,49 @@ +using Jint.Constraints; + namespace Jint; public partial class Engine { public ConstraintOperations Constraints { get; } + [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Auto)] + private readonly record struct ConstraintPartition(Constraint[] Exact, Constraint[] Amortized); + + /// + /// Splits the registered constraints by required check frequency. The built-in time, + /// cancellation and memory-limit constraints only observe external state (a timer, a token, + /// an allocation counter), so checking them every N statements is semantically equivalent to + /// checking per statement — only the detection latency is bounded instead of immediate (the + /// same reasoning bulk built-ins apply via ). Everything + /// else stays exact: counts statements, so its call + /// frequency IS its semantics, and user-derived constraints may depend on being called once + /// per statement — silently amortizing them would be a breaking behavior change. + /// + private static ConstraintPartition PartitionConstraints(Constraint[] constraints) + { + if (constraints.Length == 0) + { + return new ConstraintPartition([], []); + } + + var exact = new List(constraints.Length); + var amortized = new List(constraints.Length); + foreach (var constraint in constraints) + { + // All three types are sealed, so the checks cannot match a user-derived subclass. + if (constraint is TimeConstraint or CancellationConstraint or MemoryLimitConstraint) + { + amortized.Add(constraint); + } + else + { + exact.Add(constraint); + } + } + + return new ConstraintPartition(exact.ToArray(), amortized.ToArray()); + } + public class ConstraintOperations { private readonly Engine _engine; diff --git a/Jint/Engine.cs b/Jint/Engine.cs index 3c96ffe781..b786b0c708 100644 --- a/Jint/Engine.cs +++ b/Jint/Engine.cs @@ -150,6 +150,13 @@ internal void CacheFunctionDefinition(Node key, JintFunctionDefinition definitio // cached access internal readonly IObjectConverter[]? _objectConverters; internal readonly Constraint[] _constraints; + + // _constraints partitioned once at construction (the set is fixed for the engine's lifetime): + // exact constraints must run before every statement, amortized ones every N statements. + // See Engine.Constraints.cs for the partitioning rationale. + internal readonly Constraint[] _exactConstraints; + internal readonly Constraint[] _amortizedConstraints; + internal readonly bool _isDebugMode; internal readonly bool _isStrict; @@ -248,6 +255,9 @@ private Engine(Options? options, Action? configure) : null; _constraints = Options.Constraints.Constraints.ToArray(); + var partitionedConstraints = PartitionConstraints(_constraints); + _exactConstraints = partitionedConstraints.Exact; + _amortizedConstraints = partitionedConstraints.Amortized; _referenceResolver = Options.ReferenceResolver; _customResolver = !ReferenceEquals(_referenceResolver, DefaultReferenceResolver.Instance); @@ -807,6 +817,12 @@ internal void DrainEventLoopUntilSettled(JsPromise promise, TimeSpan timeout) } } + /// + /// Checks every registered constraint plus the debugger step hook. Used by native callback + /// call sites (e.g. sort comparers) where the callee may not run any JS statements itself; + /// the interpreter's per-statement path uses the partitioned + /// / pair instead. + /// internal void RunBeforeExecuteStatementChecks(StatementOrExpression? statement) { // Avoid allocating the enumerator because we run this loop very often. @@ -821,6 +837,36 @@ internal void RunBeforeExecuteStatementChecks(StatementOrExpression? statement) } } + /// + /// The exactly-once-per-statement slice of the before-statement checks: constraints whose + /// call frequency is observable (statement counting, user-derived) and the debugger step hook. + /// + internal void RunPerStatementChecks(StatementOrExpression? statement) + { + // Avoid allocating the enumerator because we run this loop very often. + foreach (var constraint in _exactConstraints) + { + constraint.Check(); + } + + if (_isDebugMode && statement != null && statement.Type != NodeType.BlockStatement) + { + Debugger.OnStep(statement); + } + } + + /// + /// Checks the constraints that only observe external state; callers are responsible for the + /// amortization cadence (see ). + /// + internal void CheckAmortizedConstraints() + { + foreach (var constraint in _amortizedConstraints) + { + constraint.Check(); + } + } + internal JsValue GetValue(object value) { return GetValue(value, false); diff --git a/Jint/Runtime/Interpreter/EvaluationContext.cs b/Jint/Runtime/Interpreter/EvaluationContext.cs index 830e524e32..c1eefcde8d 100644 --- a/Jint/Runtime/Interpreter/EvaluationContext.cs +++ b/Jint/Runtime/Interpreter/EvaluationContext.cs @@ -8,13 +8,26 @@ namespace Jint.Runtime.Interpreter; /// internal sealed class EvaluationContext { - private readonly bool _shouldRunBeforeExecuteStatementChecks; + /// + /// How many statements may execute between checks of the amortized constraints (see + /// Engine.Constraints.cs for the partition rationale). Small enough that timeout / + /// cancellation / memory-limit detection latency stays far below anything observable at + /// the granularity those constraints operate on, large enough that the per-statement cost + /// collapses to a countdown decrement and branch. + /// + internal const int AmortizedConstraintCheckInterval = 64; + + private readonly bool _shouldRunPerStatementChecks; + private readonly bool _hasAmortizedConstraints; + private int _amortizedConstraintCountdown; public EvaluationContext(Engine engine) { Engine = engine; OperatorOverloadingAllowed = engine.Options.Interop.AllowOperatorOverloading; - _shouldRunBeforeExecuteStatementChecks = engine._constraints.Length > 0 || engine._isDebugMode; + _shouldRunPerStatementChecks = engine._exactConstraints.Length > 0 || engine._isDebugMode; + _hasAmortizedConstraints = engine._amortizedConstraints.Length > 0; + _amortizedConstraintCountdown = AmortizedConstraintCheckInterval; } // for fast evaluation checks only @@ -22,7 +35,8 @@ public EvaluationContext() { Engine = null!; OperatorOverloadingAllowed = false; - _shouldRunBeforeExecuteStatementChecks = false; + _shouldRunPerStatementChecks = false; + _hasAmortizedConstraints = false; } public readonly Engine Engine; @@ -32,7 +46,7 @@ public EvaluationContext() /// Frozen per context (constraints or debug mode at creation); statement fast paths that /// skip entirely must be gated on this. /// - internal bool ShouldRunBeforeExecuteStatementChecks => _shouldRunBeforeExecuteStatementChecks; + internal bool ShouldRunBeforeExecuteStatementChecks => _shouldRunPerStatementChecks || _hasAmortizedConstraints; /// /// Returns true if the generator is suspended (yielded) or a return was requested. @@ -78,9 +92,28 @@ public Node LastSyntaxElement public void RunBeforeExecuteStatementChecks(StatementOrExpression statement) { - if (_shouldRunBeforeExecuteStatementChecks) + if (_shouldRunPerStatementChecks) + { + Engine.RunPerStatementChecks(statement); + } + + RunAmortizedConstraintChecks(); + } + + /// + /// The amortized slice of the before-statement checks: with only observation-only constraints + /// registered (e.g. a timeout — the common embedder configuration) this is the whole + /// per-statement cost, a countdown decrement and branch. The countdown is per-context state, + /// so detection latency stays bounded at + /// statements regardless of which call sites drive it. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void RunAmortizedConstraintChecks() + { + if (_hasAmortizedConstraints && --_amortizedConstraintCountdown == 0) { - Engine.RunBeforeExecuteStatementChecks(statement); + _amortizedConstraintCountdown = AmortizedConstraintCheckInterval; + Engine.CheckAmortizedConstraints(); } } From 2fbe79b93dc063bea763479d0fed123d979af88a Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Mon, 13 Jul 2026 15:39:59 +0300 Subject: [PATCH 2/4] Keep the tight for-body lane armed under amortized-only constraints The tight for-body lane was gated on no constraints of any kind being registered, so a mere options.TimeoutInterval(...) - the most common embedder safety net - pushed every eligible function-local loop onto the generic per-statement path, costing ~13% wall clock on loop-heavy workloads. Split the gate along the constraint partition: the lane now only requires that no exact (per-statement) constraints and no debug mode are active. Amortized constraints (timeout, cancellation, memory limit) stay live inside TightForBody by driving the EvaluationContext's shared amortized countdown once per iteration - the same countdown the per-statement path uses, so detection latency stays bounded at 64 statements/iterations regardless of which path executes. With no amortized constraints the per-iteration cost is a load and a predicted branch. The consumer-less combined ShouldRunBeforeExecuteStatementChecks property is replaced by ShouldRunPerStatementChecks, with the fast-path gating invariant re-documented: lanes that skip per-statement checks must gate on it AND drive RunAmortizedConstraintChecks at a bounded cadence. Sanity-checked with the REPL: a 20M-iteration function-local loop now runs at unconstrained speed with a 30s timeout configured (721ms vs 737ms unconstrained, identical results); before this change the lane disarmed. Tests: memory limit interrupts an allocating tight-lane loop, and a timeout-configured engine produces results identical to an unconstrained one on the exact tight-lane loop shape (timeout-fires-inside-tight-loop and exact-constraints-disarm-the-lane were added with the partition commit). Co-Authored-By: Claude Fable 5 --- .../Runtime/ExecutionConstraintTests.cs | 22 +++++++++++++++++++ Jint/Runtime/Interpreter/EvaluationContext.cs | 8 ++++--- .../Statements/JintForStatement.cs | 14 +++++++++--- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/Jint.Tests/Runtime/ExecutionConstraintTests.cs b/Jint.Tests/Runtime/ExecutionConstraintTests.cs index 037ff773a3..3415c43730 100644 --- a/Jint.Tests/Runtime/ExecutionConstraintTests.cs +++ b/Jint.Tests/Runtime/ExecutionConstraintTests.cs @@ -113,6 +113,28 @@ public void ShouldThrowTimeoutInsideFunctionLocalTightLoop() ); } + [Fact] + public void ShouldThrowMemoryLimitExceededInsideFunctionLocalTightLoop() + { + // Memory limit is amortized too and must interrupt an allocating tight-lane loop. + var engine = new Engine(cfg => cfg.LimitMemory(2_000_000)); + Assert.Throws( + () => engine.Evaluate("function f() { var s = ''; for (var i = 0; i < 1; i += 0) { s += 'aaaaaaaaaaaaaaaa'; } return s; } f();") + ); + } + + [Fact] + public void TimeoutConstraintDoesNotChangeFunctionLocalLoopResults() + { + // With only amortized constraints registered the tight for-body lane stays armed; + // results must be identical to an unconstrained engine's. + const string script = "function f() { var s = 0; for (var i = 0; i < 100000; i++) { s += 2; } return s; } f();"; + var unconstrained = new Engine().Evaluate(script).AsNumber(); + var constrained = new Engine(cfg => cfg.TimeoutInterval(TimeSpan.FromSeconds(30))).Evaluate(script).AsNumber(); + Assert.Equal(unconstrained, constrained); + Assert.Equal(200_000, constrained); + } + [Fact] public void ShouldThrowExecutionCanceled() { diff --git a/Jint/Runtime/Interpreter/EvaluationContext.cs b/Jint/Runtime/Interpreter/EvaluationContext.cs index c1eefcde8d..8515d7cc3a 100644 --- a/Jint/Runtime/Interpreter/EvaluationContext.cs +++ b/Jint/Runtime/Interpreter/EvaluationContext.cs @@ -43,10 +43,12 @@ public EvaluationContext() public bool DebugMode => Engine._isDebugMode; /// - /// Frozen per context (constraints or debug mode at creation); statement fast paths that - /// skip entirely must be gated on this. + /// Frozen per context (exact constraints or debug mode at creation); statement fast paths + /// that skip must be gated on this AND keep + /// amortized constraints live by driving at a + /// bounded cadence (e.g. once per loop iteration). /// - internal bool ShouldRunBeforeExecuteStatementChecks => _shouldRunPerStatementChecks || _hasAmortizedConstraints; + internal bool ShouldRunPerStatementChecks => _shouldRunPerStatementChecks; /// /// Returns true if the generator is suspended (yielded) or a return was requested. diff --git a/Jint/Runtime/Interpreter/Statements/JintForStatement.cs b/Jint/Runtime/Interpreter/Statements/JintForStatement.cs index d491540bb2..4530a70668 100644 --- a/Jint/Runtime/Interpreter/Statements/JintForStatement.cs +++ b/Jint/Runtime/Interpreter/Statements/JintForStatement.cs @@ -511,14 +511,17 @@ private Completion ForBodyEvaluation(EvaluationContext context, JsValue initialV var v = initialValue; // Tight loop: with an expression-statements-only body, a non-suspendable frame, no - // constraint/debug checks, dead completion values and no fresh per-iteration environment, - // nothing per iteration remains observable but test, body expressions and update. + // per-statement (exact) constraint/debug checks, dead completion values and no fresh + // per-iteration environment, nothing per iteration remains observable but test, body + // expressions and update. Amortized constraints (timeout, cancellation, memory limit) + // do not disarm the lane: TightForBody drives the shared amortized countdown once per + // iteration, keeping their detection latency bounded. // The DebugMode probe is live (same coarseness as the debugHandler hoisting below). if (_tightBodyEligible && !skipTestOnce && !resumeUpdateOnce && !context.CompletionValuesObservable - && !context.ShouldRunBeforeExecuteStatementChecks + && !context.ShouldRunPerStatementChecks && !context.DebugMode && (!_shouldCreatePerIterationEnvironment || _canReuseIterationEnvironment) && (!_tightBodyHasLexicalDeclarations || flattenActive) @@ -656,6 +659,9 @@ private Completion ForBodyEvaluation(EvaluationContext context, JsValue initialV /// loop environment carries the body's slots: their TDZ is re-established per iteration /// before the body statements run, mirroring the generic flattened arm — skipped when the /// block scan proved every slot initializes before any use (leftovers are unobservable). + /// Amortized constraints stay live through the context's shared countdown, driven once per + /// iteration (a tripped constraint throws and propagates like any other exception); exact + /// constraints and debug mode never reach this lane by the caller's gate. /// [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] private Completion TightForBody(EvaluationContext context, bool flattenActive) @@ -669,6 +675,8 @@ private Completion TightForBody(EvaluationContext context, bool flattenActive) while (test.GetBooleanValue(context)) { + context.RunAmortizedConstraintChecks(); + if (resetBodySlotsPerIteration) { var env = (DeclarativeEnvironment) engine.ExecutionContext.LexicalEnvironment; From 57b0282af17bb1ced68bc14fb001abbb1509fe84 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Mon, 13 Jul 2026 15:40:54 +0300 Subject: [PATCH 3/4] Add ConstrainedExecutionBenchmark for constraint-check overhead A function-local numeric loop (1M iterations, tight for-body lane shape) run on a reused engine with a prepared script, parameterized on whether options.TimeoutInterval is configured. Measures the per-statement cost a registered timeout adds - the delta the amortized-constraint partition is meant to eliminate. Co-Authored-By: Claude Fable 5 --- .../ConstrainedExecutionBenchmark.cs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 Jint.Benchmark/ConstrainedExecutionBenchmark.cs diff --git a/Jint.Benchmark/ConstrainedExecutionBenchmark.cs b/Jint.Benchmark/ConstrainedExecutionBenchmark.cs new file mode 100644 index 0000000000..e5cb620559 --- /dev/null +++ b/Jint.Benchmark/ConstrainedExecutionBenchmark.cs @@ -0,0 +1,44 @@ +using BenchmarkDotNet.Attributes; +using Jint.Native; + +namespace Jint.Benchmark; + +/// +/// Per-statement constraint check overhead: a registered timeout (the most common embedder +/// safety net) used to force a virtual Check() call before every executed statement and +/// disarm the tight for-body lane. The amortized-constraint partition reduces that to a +/// countdown decrement per statement and keeps the lane armed, so the TimeoutEnabled=true +/// row should track the unconstrained row closely. +/// +[MemoryDiagnoser] +public class ConstrainedExecutionBenchmark +{ + private Engine _engine = null!; + private Prepared