Amortize observation-only constraint checks and keep tight loops armed under timeouts#2672
Merged
Merged
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
The tight-lane entry's documented precondition said callers guarantee no constraint checks at all; since the amortized-constraint split, only exact constraint/debug checks disarm the lane while amortized ones stay live via the caller's countdown cadence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lahma
enabled auto-merge (squash)
July 13, 2026 13:58
lahma
added a commit
that referenced
this pull request
Jul 15, 2026
…2695) The tight-loop constraint amortization (#2672) checks the Time, Cancellation and MemoryLimit constraints once every 64 statements. That is sound for a clock or a cancellation token - a check reads external state it does not consume - but allocation is irreversible and unbounded per statement: a single iteration can allocate arbitrarily much (e.g. `s += s` doubling), so a function-body tight loop can overshoot the configured cap, up to a real OutOfMemoryException, before the next 64-statement check fires. v4.12.0 had no while/do-while tight lane and checked memory every statement. Move MemoryLimitConstraint into the exact set so memory-limited engines check it per statement again (as before the tight lane), preserving the limit as a hard-ish bound for sandboxing untrusted code. Time and cancellation stay amortized, so the common timeout-only / unconstrained engine keeps the tight-lane fast path unchanged. Claude-Session: https://claude.ai/code/session_01XM8rSnn8j66kDCTaP2exNK Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jul 15, 2026
lahma
added a commit
that referenced
this pull request
Jul 20, 2026
…2713) The amortization from #2672 bounds timeout/cancellation detection latency in statement count (64), which tracks wall-clock time only while statements stay cheap; a statement that calls user CLR code can take arbitrarily long, so a loop of slow host calls could stretch the detection window to minutes (discussion #2707). Interop call sites that hand control to user CLR code (delegates, wrapped methods, property/field/indexer accessors, CLR constructors) now re-check the amortized constraints on entry and after the host code returns, bounding detection latency by a single host call again. Built-in ClrFunction dispatch stays check-free by design. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Registering any execution constraint — including the extremely common
options.TimeoutInterval(...)— makes every statement pay a virtualConstraint.Check()call, and worse, freezesEvaluationContext._shouldRunBeforeExecuteStatementCheckstotrue, which disarms the tight for-body lane entirely. Measured on a function-local numeric loop: configuring a 30 s timeout costs +95% (21.3 → 41.6 ms) on current main.The built-in
TimeConstraint,CancellationConstraintandMemoryLimitConstraintonly observe external state (IsCancellationRequested, memory counters) — checking them every statement buys nothing over checking every N statements, and bulk built-ins already amortize via theConstraintCheckIntervalidiom.MaxStatementsConstraintcounts statements (its call frequency is its semantics) and user-derivedConstraintsubclasses have unknown semantics, so both must stay exact.Changes:
EvaluationContextcountdown. With no constraints the countdown isn't even written; debug-mode stepping is unchanged.!ShouldRunPerStatementChecks, andTightForBodydrives the same shared countdown once per iteration, so timeout/cancellation/memory detection latency stays bounded at 64 statements/iterations across path transitions. Any exact constraint or debugger still disarms the lane as before.ConstrainedExecutionBenchmark(TimeoutEnabledparam) covering this configuration — the suite previously had no constraint-enabled lane.Benchmarks (default jobs, adjacent A/B; A = main + benchmark commit only)
The timeout tax drops from +95% to +1.8%.
ETW confirmation (ultra @ 8190 Hz, mixed SunSpider driver,
-t 120timeout configured): total 18,107 → 14,173 ms (−21.7%, includes lane merges since the baseline);TightForBodynow appears (6.4% self) under a timeout,Engine.RunBeforeExecuteStatementChecksleaves the top frames,JintStatementList.Executeself 14.7% → 7.1% — the constrained profile now matches the unconstrained one.Semantics
Constraintis still checked once per executed statement — locked in by a new counting test.MaxStatementsConstraintcounting is unchanged (tests assert exact counts, including inside tight-shaped loops where it disarms the lane).Engine.RunBeforeExecuteStatementChecksused by sort comparers) keeps full-set semantics.Gates
🤖 Generated with Claude Code