Skip to content

Amortize observation-only constraint checks and keep tight loops armed under timeouts#2672

Merged
lahma merged 7 commits into
sebastienros:mainfrom
lahma:perf-amortized-constraints
Jul 13, 2026
Merged

Amortize observation-only constraint checks and keep tight loops armed under timeouts#2672
lahma merged 7 commits into
sebastienros:mainfrom
lahma:perf-amortized-constraints

Conversation

@lahma

@lahma lahma commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

Registering any execution constraint — including the extremely common options.TimeoutInterval(...) — makes every statement pay a virtual Constraint.Check() call, and worse, freezes EvaluationContext._shouldRunBeforeExecuteStatementChecks to true, 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, CancellationConstraint and MemoryLimitConstraint only observe external state (IsCancellationRequested, memory counters) — checking them every statement buys nothing over checking every N statements, and bulk built-ins already amortize via the ConstraintCheckInterval idiom. MaxStatementsConstraint counts statements (its call frequency is its semantics) and user-derived Constraint subclasses have unknown semantics, so both must stay exact.

Changes:

  1. Partition constraints at Engine construction into exact (MaxStatements + any user subclass) and amortized (the three sealed observation-only built-ins). Per statement: exact constraints run every time; amortized ones run every 64 statements via an EvaluationContext countdown. With no constraints the countdown isn't even written; debug-mode stepping is unchanged.
  2. Keep the tight for-body lane armed under amortized-only constraints: the lane gate becomes !ShouldRunPerStatementChecks, and TightForBody drives 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.
  3. New ConstrainedExecutionBenchmark (TimeoutEnabled param) covering this configuration — the suite previously had no constraint-enabled lane.

Benchmarks (default jobs, adjacent A/B; A = main + benchmark commit only)

ConstrainedExecutionBenchmark FunctionLocalLoop main PR Δ
TimeoutEnabled=false 21.34 ms 21.01 ms neutral
TimeoutEnabled=true 41.59 ms 21.39 ms −48.6%

The timeout tax drops from +95% to +1.8%.

ETW confirmation (ultra @ 8190 Hz, mixed SunSpider driver, -t 120 timeout configured): total 18,107 → 14,173 ms (−21.7%, includes lane merges since the baseline); TightForBody now appears (6.4% self) under a timeout, Engine.RunBeforeExecuteStatementChecks leaves the top frames, JintStatementList.Execute self 14.7% → 7.1% — the constrained profile now matches the unconstrained one.

Semantics

  • A user-derived Constraint is still checked once per executed statement — locked in by a new counting test.
  • MaxStatementsConstraint counting is unchanged (tests assert exact counts, including inside tight-shaped loops where it disarms the lane).
  • Timeout / cancellation / memory-limit constraints still fire inside armed tight loops (new tests) with detection latency bounded at 64 iterations.
  • The native-callback call site (Engine.RunBeforeExecuteStatementChecks used by sort comparers) keeps full-set semantics.

Gates

  • Jint.Tests: 3547 (net10.0) + 3484 (net472) passed, 0 failed (includes 6 new constraint tests)
  • Jint.Tests.PublicInterface: 82 × 2 TFMs passed
  • Jint.Tests.CommonScripts: green both TFMs
  • Test262: 99,429 passed, 0 failed

🤖 Generated with Claude Code

lahma and others added 4 commits July 13, 2026 16:39
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
lahma enabled auto-merge (squash) July 13, 2026 13:58
@lahma
lahma merged commit 4608732 into sebastienros:main Jul 13, 2026
4 checks passed
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant