Serve nested-scope global reads and writes from the global-binding cache#2584
Merged
Conversation
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>
This was referenced Jul 13, 2026
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.
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/Datein 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:
TryGetValidatedGlobalDescriptoraccepts 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 directevalinjected avar), 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).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
MutableBindingflag. Writers now re-checkWritablethrough the cached reference, sincedefinePropertyflips flags in place without bumping the versions the validator checks.LazyPropertyDescriptorclearsCustomJsValueonce 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 toJintMemberExpression'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.
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):Allocations unchanged on every suite.
Verification
Jint.Testsgreen (net10.0 + net472),Jint.Tests.PublicInterfacegreenwithshadowing semantics the walk probes protect)🤖 Generated with Claude Code