Skip to content

Serve nested-scope global reads and writes from the global-binding cache#2584

Merged
lahma merged 2 commits into
sebastienros:mainfrom
lahma:perf-nested-global-cache
Jul 6, 2026
Merged

Serve nested-scope global reads and writes from the global-binding cache#2584
lahma merged 2 commits into
sebastienros:mainfrom
lahma:perf-nested-global-cache

Conversation

@lahma

@lahma lahma commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

The identifier-level global-binding cache (descriptor + version validation) previously required the current lexical environment to be the global environment, so it only served top-level code. Reads of globals from loop bodies and closures — sw/Date in the stopwatch scripts, the helper functions and tables the dromaeo family calls constantly — walked the environment chain and repeated the global property lookup on every access.

Three changes let nested scopes use the cache safely:

  1. TryGetValidatedGlobalDescriptor accepts nested scopes via a bounded walk that mirrors the existing slot-cache walk: any intermediate with-object, or declarative environment that has the binding (e.g. a sloppy direct eval injected a var), bails to full resolution — the per-read probes are the correctness pin. Hop 0 keeps its single identity compare up front in a compact method; the walk lives in a separate non-inlined method so var-heavy scripts that validate at hop 0 many times per loop iteration don't pay for its code size (an earlier single-method version cost classic stopwatch +3.5% purely through method growth).

  2. Cache population no longer requires hop-0 resolution (read and write sides), and the admission gate becomes "plain writable data descriptor without CustomValue indirection" instead of requiring the MutableBinding flag. Writers now re-check Writable through the cached reference, since defineProperty flips flags in place without bumping the versions the validator checks.

  3. LazyPropertyDescriptor clears CustomJsValue once materialized — from that point it is semantically a plain data descriptor (its getter returns _value, its setter writes _value), which admits materialized global built-ins (Date & co.) to this cache and to JintMemberExpression's write cache.

Benchmarks

BenchmarkDotNet default jobs, win-x64, .NET 10, A/B against main (with #2583) from separate worktrees; baseline run twice to establish per-suite noise bands.

case main PR delta
Stopwatch Execute (modern) 179.3 / 182.0 ms 170.2 ms −5.1..−6.5%
Stopwatch Execute_ParsedScript (modern) 178.9 / 177.5 ms 173.4 ms −2.3..−3.1%
Stopwatch Execute (classic) 154.4 / 155.4 ms 155.5 ms neutral
Stopwatch Execute_ParsedScript (classic) 153.1 / 154.0 ms 150.6 ms −2%
GlobalVarLoop / LocalVarLoop / GlobalUpdateLoop 41.7-43.3 / 39.1-40.7 / 85.7-88.3 42.6 / 39.3 / 86.2 inside baseline bands
DromaeoBenchmark (engine-reuse suite, 24 cases) no regression; Cube modern-prepared −8%

The engine-comparison README table measures a different shape — fresh engine per op, strict, source or prepared (EngineComparisonBenchmark). A/B with that shape (--profile-cpu, 20-50 iters, alternating binaries):

script main PR delta
dromaeo-string-base64 38.3 ms/iter 32.5 ms/iter −15%
dromaeo-3d-cube 22.1 ms/iter 20.0 ms/iter −10%
dromaeo-object-string 74.2 ms/iter 70.7 ms/iter −5%

Allocations unchanged on every suite.

Verification

  • Jint.Tests green (net10.0 + net472), Jint.Tests.PublicInterface green
  • Full test262: 99,260 passed, 0 failed (covers the sloppy-eval var-injection and with shadowing semantics the walk probes protect)

🤖 Generated with Claude Code

lahma and others added 2 commits July 6, 2026 15:39
The identifier-level global-binding cache previously required the current
lexical environment to BE the global environment, so reads of globals from
loop bodies and closures (sw/Date in stopwatch, helper functions in the
dromaeo family) walked the environment chain and did the property lookup
every time.

- TryGetValidatedGlobalDescriptor now accepts nested scopes via a bounded
  walk mirroring the slot-cache walk: any intermediate with-object or
  declarative environment holding the name (e.g. sloppy direct eval var
  injection) bails to full resolution; the per-read probes are the
  correctness pin. Hop 0 keeps its single identity compare up front.
- Cache population no longer requires hop-0 resolution (read and write
  sides), and the cache admits any plain writable data descriptor without
  CustomValue indirection instead of requiring MutableBinding; writers now
  re-check Writable through the cached reference since defineProperty flips
  flags in place without bumping the validated versions.
- LazyPropertyDescriptor clears CustomJsValue once materialized — from then
  on it is semantically a plain data descriptor, which admits materialized
  global built-ins (Date & co) to this cache and the member-write cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Classic (var-heavy) stopwatch validates the global cache ~12 times per
inner-loop iteration at hop 0; folding the nested-scope walk into the same
method cost it +3.5% through method growth alone. The hop-0 arm keeps its
single identity compare in a compact method; the bounded walk moves to a
separate non-inlined method that only nested-scope hits pay for.

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