Skip to content

Slot-backed strict-eval environments with per-source pooling#2565

Merged
lahma merged 4 commits into
sebastienros:mainfrom
lahma:eval-slot-env-pr1
Jul 4, 2026
Merged

Slot-backed strict-eval environments with per-source pooling#2565
lahma merged 4 commits into
sebastienros:mainfrom
lahma:eval-slot-env-pr1

Conversation

@lahma

@lahma lahma commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Strict eval creates a fresh environment per call, and it was always dictionary-backed: every identifier access in the eval body paid a dictionary probe, and the per-AST-node slot caches (identifier reads, unboxed i++ updates) could never engage — while the identical body under new Function() runs on fixed slots. dromaeo-core-eval spends about half of each op inside one such body.

What this does

  • The eval compile-cache entry now precomputes the environment's fixed-slot layout (deduplicated var + function-declaration names initialized to undefined, let/const as TDZ templates) plus the hoisting scope, so PerformEval instantiates the strict-eval environment directly from templates.
  • EvalDeclarationInstantiation gains the precomputed HoistingScope parameter (kills a per-call AST walk) and a bindingsPreInitialized flag that skips the var/lexical passes; its function bookkeeping (LinkedList/HashSet) is now allocated lazily — a win for all function-free evals, sloppy included.
  • Entries whose body cannot capture the environment (no closures, nested eval or with — the existing EnvironmentEscapeAstVisitor.MayEscape gate) pool the environment across calls, keeping the shared statement tree's per-node slot caches valid on reused engines.
  • The layout is built only when it can amortize: a loop in the body (many accesses in one call) or a promoted source (repeats, pooled env carries caches across calls). One-shot loopless evals pay nothing.
  • ShadowRealm's eval path reuses CachedHoistingScope for prepared scripts instead of re-walking the AST per call.

Non-strict direct eval (vars land in the caller's environment) is untouched.

Measurements (same-window stash A/B, BenchmarkDotNet default job)

Benchmark main PR Δ
EvalHotReusedEngine 1,313.2 µs / 79.2 KB 1,069.6 µs / 77.0 KB −18.6%
EvalFreshEngine 1,279.7 µs / 174.6 KB 1,056.3 µs / 174.7 KB −17.5%
EvalSameSource 264.9 µs / 80.7 KB 199.4 µs / 42.0 KB −24.7% time, −47.9% alloc
EvalDistinctSources (miss-path guard) 101.4 µs / 201.2 KB 100.9 µs / 201.2 KB flat
NewFunctionHot / NewFunctionFresh / PlainFunctionLoopReference / ParseOnlyTmp within noise
ShadowRealm 2,900 ns 2,824 ns −2.6%
ShadowRealm_ParsedScript 1,607 ns 1,460 ns −9.2%
dromaeo-core-eval (CpuProfileDriver ×400, source) 4.021 ms/iter 3.679 ms/iter −8.5%
dromaeo-core-eval (CpuProfileDriver ×400, prepared) 4.154 ms/iter 3.917 ms/iter −5.7%

EvalExecutionBenchmarks (new in this PR) isolates the dromaeo-core-eval execution shape; --profile-time (new) splits dromaeo scripts per test() with Stopwatch, complementing --profile-memory.

Together with the follow-up compound-assignment PR, the EngineComparison dromaeo-core-eval row moves 2.507 → 1.941 ms (−22.6%) / prepared 2.480 → 1.909 ms (−23.0%), allocation flat (same-runtime worktree A/B).

Gates

  • Jint.Tests 3159/3097 (net10/net472), Jint.Tests.PublicInterface 79/79, Jint.Tests.CommonScripts 28/28 — all green
  • Test262: 99,260 passed, 0 failed (133 skipped)
  • All-TFM Jint.csproj Release build
  • New EvalTests cover: pooled-env binding freshness, closures escaping eval (pooling gate), re-entrant same-source eval, function-declaration hoisting in slots, const TypeError, let TDZ, >cap fallback, sloppy leak semantics, indirect strict eval

🤖 Generated with Claude Code

lahma and others added 2 commits July 3, 2026 06:52
EvalExecutionBenchmarks isolates the dromaeo-core-eval execution shape
(hot/fresh eval, new Function, plain-function reference floor, parse-only
bound); TimeProbe (--profile-time) splits dromaeo scripts per test() with
Stopwatch, complementing MemoryProbe's allocation split.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Strict eval created a fresh dictionary-backed environment per call, so
every identifier access in the eval body paid a dictionary probe and the
per-AST-node slot caches (identifier reads, unboxed i++) could never
engage - while the identical body under new Function() runs on fixed
slots. dromaeo-core-eval spends about half of each op inside one such
body.

The eval compile-cache entry now precomputes the environment slot layout
(deduplicated var + function names initialized to undefined, let/const
as TDZ templates) plus the hoisting scope; PerformEval instantiates the
strict-eval environment from the templates and
EvalDeclarationInstantiation skips the var/lexical passes via a new
bindingsPreInitialized flag. Bodies that cannot capture the environment
(no closures, nested eval or with) pool it across calls, keeping the
shared statement tree per-node caches valid. The layout is built only
when it can amortize: a loop in the body, or a source seen twice
(promotion). Function bookkeeping in EvalDeclarationInstantiation is
now lazily allocated, and ShadowRealm reuses CachedHoistingScope for
prepared scripts instead of re-walking the AST per eval.

Same-window stash A/B: EvalHotReusedEngine -18.6%, EvalFreshEngine
-17.5%, EvalSameSource -24.7% time / -47.9% alloc, EvalDistinctSources
flat (miss guard), ShadowRealm_ParsedScript -9.2%, dromaeo-core-eval
driver -8.5% source / -5.7% prepared; new Function and plain-function
reference rows unchanged. Jint.Tests 3159/3097, PublicInterface 79/79,
CommonScripts 28/28, Test262 99260/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Code-review findings on the slot-backed eval work, all reproduced before
fixing:

The identifier read fast path's cached-slot walk hopped over intermediate
declarative environments by reference comparison only, so a node whose
cache pointed at an outer environment read a stale binding when executed
under a chain that shadows the name - reachable through the eval
compilation cache (the same body under a shadowing block or a nested
eval of the same source) and through sloppy direct eval injecting a var
into an enclosing function environment. The walk now probes each hopped
declarative environment with HasBinding (pure; the common hop-0 hit pays
nothing) and falls back to full resolution when an intermediate
environment owns the name.

EvalDeclarationInstantiation validated eval-declared var names with
CanDeclareGlobalFunction where the spec requires CanDeclareGlobalVar
(sec-evaldeclarationinstantiation step 8.a.ii.1), so eval("var undefined;")
and redeclaration of any existing non-configurable global threw a
spurious TypeError; pre-existing, but the lines live in this change.

TryPoolEvalEnvironment parked environments with the completed call's
binding values still in the slots, keeping arbitrary object graphs
reachable from the eval cache for the engine's lifetime; the slots are
now reset to templates (and the dictionary and dispose capability
cleared) at park time, and the rent path no longer needs per-rent
cleanup.

EvalScriptAnalyzer no longer lets loops inside arrow functions mark the
eval body as loop-bearing (arrows still descend for arguments/new.target
detection); those loops run in the arrow's own environment and cannot
amortize the eval body's slot layout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lahma

lahma commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Adversarial multi-agent review of the stack surfaced issues; fixed in fbd90a5:

  • Shadow-aware read walk: the identifier slot-cache hit walk hopped intermediate environments by reference only, so a cached-eval body executing under a chain that shadows the name (a let/const block at a different call site, a nested eval of the same source, or sloppy eval var-injection) read a stale outer binding. The walk now probes each hopped declarative environment with HasBinding (pure; hop-0 hits — all the hot loops — pay nothing). Part of this was reproducible on main before this PR; slot-backed eval environments widen its reach, so the fix ships here.
  • Spec fix: eval var names were validated with CanDeclareGlobalFunction instead of CanDeclareGlobalVar (sec-evaldeclarationinstantiation 8.a.ii.1) — eval("var undefined;") threw a spurious TypeError. Pre-existing; the lines live in this diff.
  • Pool hygiene: pooled eval environments parked with the completed call's binding values still in the slots (engine-lifetime retention of arbitrary object graphs); slots now reset to templates at park time.
  • Loops inside arrow bodies no longer mark the eval body loop-bearing for the slot-layout gate.

Five regression tests cover the repro shapes. Full suites + Test262 99,260/0 re-verified on the stack.

🤖 Generated with Claude Code

@lahma
lahma enabled auto-merge (squash) July 4, 2026 08:25
@lahma
lahma merged commit 08d00dd into sebastienros:main Jul 4, 2026
4 checks passed
@lahma
lahma deleted the eval-slot-env-pr1 branch July 13, 2026 06:52
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