Add version-gated inline cache for global variable bindings#2507
Merged
Conversation
lahma
added a commit
to lahma/jint
that referenced
this pull request
Jun 7, 2026
Jint rows now reflect the empty-FDI, string-slice-view and global-binding-cache changes (PRs sebastienros#2502, sebastienros#2506, sebastienros#2507): object-string ~61 ms / 139 MB (was ~170 ms / 1.3 GB), stopwatch ~180 ms. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Top-level identifier reads and writes previously paid a property dictionary lookup (writes: chain resolution plus a second lookup) on every access. JintIdentifierExpression now caches the resolved global descriptor when the current lexical env IS the global env and the binding is a plain writable MutableBinding data property on the real GlobalObject with no lexical declaration shadowing it. Validity is version-gated: the global object''s own-property shape version plus a new GlobalEnvironment._lexicalMutations counter (bumped when the set of global let/const declarations changes); in-place value writes bump neither. Cache hits read/write the descriptor value directly, mirroring GlobalObject.SetFromMutableBinding''s fast path. Inlining shape matters: a null pre-check on the cache field keeps the cost for non-global identifiers to a single field test, while the validator and the write arm stay out-of-line (NoInlining) - inlining them taxed local-variable loops ~7% via code growth. Gating (new GlobalAccessBenchmarks, tight same-window A/B): - GlobalVarLoop 58.40ms -> 43.26ms (-25.9%), now faster than the fixed-slot LocalVarLoop ceiling - LocalVarLoop guard clean (53.20 -> 50.89ms) - EngineComparison stopwatch wall-clock 207.5 -> 186.0 ms/iter (-10.4%) - Jint.Tests 0 failures (net10.0 + net472), PublicInterface green, Test262 99208 passed / 0 failed Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lahma
force-pushed
the
global-binding-cache
branch
from
June 8, 2026 13:35
1393cca to
93531f5
Compare
lahma
added a commit
to lahma/jint
that referenced
this pull request
Jun 8, 2026
Fresh full EngineComparison run on main with all five engine-comparison perf PRs merged (sebastienros#2502 empty-FDI, sebastienros#2503 eval cache, sebastienros#2504 prepared-script fix, sebastienros#2506 string slice views, sebastienros#2507 global-binding cache). Jint now: - dromaeo-object-string ~58-61 ms / 139 MB (rank 1, was ~170 ms / 1.3 GB and losing to NiL.JS) and the -modern variant likewise rank 1 - array-stress rank 1 (3,290 us, ParsedScript) - stopwatch 174-181 ms (was ~205 ms) - still the lowest allocations in nearly every workload Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lahma
added a commit
that referenced
this pull request
Jun 8, 2026
…2505) * Refresh engine comparison results; add wall-clock profiling driver README.md: 2026-06-07 results on .NET 10.0.8 (Jurassic 3.2.9, NiL.JS 2.6.1722, YantraJS.Core 1.2.404 - all latest). Notes the severe YantraJS 1.2.404 regression on dromaeo-object-regexp (~35 s/op, ~32 GB allocated; 1.2.344 ran it at ~1 s/op). CpuProfileDriver adds a `--profile-cpu <script> <iters> <source|prepared>` mode mirroring EngineComparisonBenchmark per-op shape (fresh strict engine per iteration, dromaeo helper stubs) for quick wall-clock A/B comparisons, complementing the existing --profile-memory probe. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add DateConstructionBenchmarks guard Records a falsified optimization hypothesis: skipping the redundant TimeClip + double round-trip in the `new Date()` now-path showed no measurable win (15.15ms +/- 0.38 -> 15.19ms +/- 0.21 per 100k constructions) because per-construction cost is dominated by dispatch, allocation and the UtcNow syscall. Kept as a guard for future Date work. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Update results table with run against fully-merged main Fresh full EngineComparison run on main with all five engine-comparison perf PRs merged (#2502 empty-FDI, #2503 eval cache, #2504 prepared-script fix, #2506 string slice views, #2507 global-binding cache). Jint now: - dromaeo-object-string ~58-61 ms / 139 MB (rank 1, was ~170 ms / 1.3 GB and losing to NiL.JS) and the -modern variant likewise rank 1 - array-stress rank 1 (3,290 us, ParsedScript) - stopwatch 174-181 ms (was ~205 ms) - still the lowest allocations in nearly every workload Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lahma
added a commit
that referenced
this pull request
Jun 9, 2026
* Add object method-call fast path reusing the member inline cache obj.method() / this.method() where the receiver is a plain identifier or `this` now resolves the callee and `this` binding through the member expression's existing version-gated own-property inline cache, avoiding the per-call Reference rent and property re-resolution. Non-callable results and all other callee shapes fall through to the unchanged Reference path; the identifier/`this` receiver is side-effect-free so that fallback never double-evaluates anything observable. MethodCallBenchmark (default job, stash A/B): MethodCallThis -10.0%, MethodCallCaptured -8.5% (the Stopwatch shape), allocation flat; the FreeFunctionCall guard is within noise. dromaeo-object-array / array-stress guards flat. Jint.Tests net10 (3067) and net472 (3005) green; Test262 99260 passed, 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Restrict method-call fast path to object receivers GetCalleeForCall's primitive-base branch called GetV, which materialized custom JsString subclasses that use lazy materialization (RavenApiUsageTests.CanInheritCustomString — "I don't want to be materialized!"). Restrict the fast path to object receivers; primitive receivers now return undefined so the call falls through to the Reference path — the original behavior, which respects Length/indexer/Equals overrides without forcing ToString(). Fixes the Jint.Tests.PublicInterface CI failure on all platforms. PublicInterface net10+net472 (79/0), Jint.Tests net10 (3067/0) green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add computed-index dense-array fast path for reads and writes Computed element access a[i] / a[i][j] / a[0] previously rented a Reference and went through the full Engine.GetValue/PutValue pipeline on every access. - Reads (JintMemberExpression.GetValue): in non-suspendable contexts, resolve the base recursively via GetValue (so chained a[i][j] reads stay rent-free at every level) and the index once, then read the dense slot directly via ArrayInstance.TryGetValueFast. A miss rents a Reference from the already-resolved operands (no re-evaluation) and completes through the normal path (factored into CompleteReadFromReference). - Writes (SimpleAssignmentExpression.SetValue): overwrite an existing in-range dense slot via ArrayInstance.TryWriteExistingDense before PutValue. Growing/hole-filling writes defer to the full path, so length and sparse semantics are unchanged. Guarded by ArrayInstance.CanUseFastAccess (clean prototype chain, default data descriptors); JsArray is sealed so typed arrays, arguments, and Proxy are excluded by construction. Holes and out-of-range indices fall through to the prototype-chain lookup. ElementAccessBenchmark (default job, stash A/B): ChainedRead (m[i][j], the MMulti shape) -20.4%, ReadModifyWrite -10.6%, Read -7.0%, Write -6.8%, allocation flat. Guards (array-stress, dromaeo-object-array, base64, regexp) flat. Jint.Tests net10 (3067) + net472 (3005), PublicInterface (79/79), Test262 99260/0 green. Stacked on #2510. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Create the constructor .prototype lazily for ordinary functions Every ordinary function declaration/expression eagerly built its .prototype object (an ObjectInstanceWithConstructor plus its constructor descriptor and the prototype descriptor) via MakeConstructor — even when the function is never used as a constructor and its .prototype is never read, e.g. the hundreds of helper functions linq-js declares. Replace the eager prototype with a memoizing LazyPropertyDescriptor that materializes the prototype on first access (new F(), or reading f.prototype). Identity is stable (memoized in _value); enumeration order is preserved (the descriptor stays non-null so Object.keys still lists 'prototype' without reading its value); attribute flags match the eager descriptor. Classes (explicit prototype) and built-in constructors are unaffected. FunctionDeclarationAllocBenchmark (default job, stash A/B): DeclareMany (500 functions, never constructed) -22.4% allocated; linq-js prepared -7.5% allocated / -4.4% time; minimal unchanged. The only cost is the lazy descriptor's two extra fields (~16 B/function) when a function IS used as a constructor (ConstructEach +1.8% allocated). Guard set flat. Jint.Tests net10 (3067) + net472 (3005), PublicInterface (79/79), Test262 99260/0. Stacked on #2511. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Cache global UpdateExpression (x++ / x--) writes #2507 cached global reads and simple assignments, but x++/x-- on a global var still did a full environment chain walk plus a second SetMutableBinding dictionary lookup per update (JintUpdateExpression.UpdateIdentifier). Extend the version-gated global-binding cache to UpdateExpression: when the identifier resolves to a cached plain writable global data property, read and write its descriptor value directly. The update slow path also now populates the cache (TryRememberGlobalBinding) so even write-only counters take the fast path after the first update. Operator-overloading contexts and eval/arguments in strict mode fall through. Invalidation is the existing global propertiesVersion + lexicalMutations. GlobalAccessBenchmarks.GlobalUpdateLoop (default job, stash A/B): -35.5% (127.1 -> 82.0 ms), allocation flat; GlobalVarLoop -6.0%; LocalVarLoop guard flat. stopwatch driver -2.8% (178.2 -> 173.1 ms/iter). Jint.Tests net10 (3067) + net472 (3005), PublicInterface (79/79), Test262 99260/0. Stacked on #2512. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lahma
added a commit
that referenced
this pull request
Jun 9, 2026
* Add object method-call fast path reusing the member inline cache obj.method() / this.method() where the receiver is a plain identifier or `this` now resolves the callee and `this` binding through the member expression's existing version-gated own-property inline cache, avoiding the per-call Reference rent and property re-resolution. Non-callable results and all other callee shapes fall through to the unchanged Reference path; the identifier/`this` receiver is side-effect-free so that fallback never double-evaluates anything observable. MethodCallBenchmark (default job, stash A/B): MethodCallThis -10.0%, MethodCallCaptured -8.5% (the Stopwatch shape), allocation flat; the FreeFunctionCall guard is within noise. dromaeo-object-array / array-stress guards flat. Jint.Tests net10 (3067) and net472 (3005) green; Test262 99260 passed, 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Restrict method-call fast path to object receivers GetCalleeForCall's primitive-base branch called GetV, which materialized custom JsString subclasses that use lazy materialization (RavenApiUsageTests.CanInheritCustomString — "I don't want to be materialized!"). Restrict the fast path to object receivers; primitive receivers now return undefined so the call falls through to the Reference path — the original behavior, which respects Length/indexer/Equals overrides without forcing ToString(). Fixes the Jint.Tests.PublicInterface CI failure on all platforms. PublicInterface net10+net472 (79/0), Jint.Tests net10 (3067/0) green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add computed-index dense-array fast path for reads and writes Computed element access a[i] / a[i][j] / a[0] previously rented a Reference and went through the full Engine.GetValue/PutValue pipeline on every access. - Reads (JintMemberExpression.GetValue): in non-suspendable contexts, resolve the base recursively via GetValue (so chained a[i][j] reads stay rent-free at every level) and the index once, then read the dense slot directly via ArrayInstance.TryGetValueFast. A miss rents a Reference from the already-resolved operands (no re-evaluation) and completes through the normal path (factored into CompleteReadFromReference). - Writes (SimpleAssignmentExpression.SetValue): overwrite an existing in-range dense slot via ArrayInstance.TryWriteExistingDense before PutValue. Growing/hole-filling writes defer to the full path, so length and sparse semantics are unchanged. Guarded by ArrayInstance.CanUseFastAccess (clean prototype chain, default data descriptors); JsArray is sealed so typed arrays, arguments, and Proxy are excluded by construction. Holes and out-of-range indices fall through to the prototype-chain lookup. ElementAccessBenchmark (default job, stash A/B): ChainedRead (m[i][j], the MMulti shape) -20.4%, ReadModifyWrite -10.6%, Read -7.0%, Write -6.8%, allocation flat. Guards (array-stress, dromaeo-object-array, base64, regexp) flat. Jint.Tests net10 (3067) + net472 (3005), PublicInterface (79/79), Test262 99260/0 green. Stacked on #2510. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Create the constructor .prototype lazily for ordinary functions Every ordinary function declaration/expression eagerly built its .prototype object (an ObjectInstanceWithConstructor plus its constructor descriptor and the prototype descriptor) via MakeConstructor — even when the function is never used as a constructor and its .prototype is never read, e.g. the hundreds of helper functions linq-js declares. Replace the eager prototype with a memoizing LazyPropertyDescriptor that materializes the prototype on first access (new F(), or reading f.prototype). Identity is stable (memoized in _value); enumeration order is preserved (the descriptor stays non-null so Object.keys still lists 'prototype' without reading its value); attribute flags match the eager descriptor. Classes (explicit prototype) and built-in constructors are unaffected. FunctionDeclarationAllocBenchmark (default job, stash A/B): DeclareMany (500 functions, never constructed) -22.4% allocated; linq-js prepared -7.5% allocated / -4.4% time; minimal unchanged. The only cost is the lazy descriptor's two extra fields (~16 B/function) when a function IS used as a constructor (ConstructEach +1.8% allocated). Guard set flat. Jint.Tests net10 (3067) + net472 (3005), PublicInterface (79/79), Test262 99260/0. Stacked on #2511. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Cache global UpdateExpression (x++ / x--) writes #2507 cached global reads and simple assignments, but x++/x-- on a global var still did a full environment chain walk plus a second SetMutableBinding dictionary lookup per update (JintUpdateExpression.UpdateIdentifier). Extend the version-gated global-binding cache to UpdateExpression: when the identifier resolves to a cached plain writable global data property, read and write its descriptor value directly. The update slow path also now populates the cache (TryRememberGlobalBinding) so even write-only counters take the fast path after the first update. Operator-overloading contexts and eval/arguments in strict mode fall through. Invalidation is the existing global propertiesVersion + lexicalMutations. GlobalAccessBenchmarks.GlobalUpdateLoop (default job, stash A/B): -35.5% (127.1 -> 82.0 ms), allocation flat; GlobalVarLoop -6.0%; LocalVarLoop guard flat. stopwatch driver -2.8% (178.2 -> 173.1 ms/iter). Jint.Tests net10 (3067) + net472 (3005), PublicInterface (79/79), Test262 99260/0. Stacked on #2512. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Pool the for-loop iteration environment across loop entries A for(let ...) loop allocated a fresh DeclarativeEnvironment on every loop entry, even when the iteration environment is already reused across iterations (no closures capture it). For loops re-entered many times — an inner for(let j) inside an outer loop, or a loop inside a frequently-called function — that is one allocation per entry. When the loop env can be reused across iterations (let binding, no captures, 1-16 bound names) and execution is non-suspendable, pool a fixed-slot environment across loop entries: reset its slots to the templates (re-establishing TDZ) and reuse the same DeclarativeEnvironment instead of allocating. Replicates JintBlockStatement's _cachedEnv/ResetSlots pattern (kept Interlocked.Exchange for parallel-fixture safety). Async/generator contexts (Suspendable != null) keep the original path so the suspend/resume save-restore machinery is unaffected. The fixed-slot environment also makes the loop variable slot-based (faster) instead of dictionary-based, helping even single-entry loops. ForBencmark.ReenteredInnerLetLoop (default job, stash A/B): -76.5% allocated (28.6 -> 6.7 MB), -15.6% time; ForLet -11.2% time, allocation flat; ForVar/ForOf* guards flat. stopwatch-modern driver -5.0% (239.7 -> 227.7 ms/iter). Jint.Tests net10 (3067) + net472 (3005), PublicInterface (79/79), Test262 99260/0. Stacked on #2514. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jun 15, 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.
Top-level identifier reads and writes paid a global-object property dictionary lookup on every access (writes: chain resolution plus a second lookup in
SetMutableBinding). Loop-heavy top-level code — thestopwatchcomparison benchmark shape — pays this hundreds of thousands of times per run.What
JintIdentifierExpressionnow carries a version-gated inline cache for global bindings, engaging when the current lexical env is the global env (top-level code) and the binding resolved to a plain writableMutableBindingdata property on the realGlobalObjectwith no global lexical declaration shadowing it. Validity:_propertiesVersion— bumped on add/remove/redefine, not on value writes), andGlobalEnvironment._lexicalMutationscounter, bumped when the set of global let/const declarations changes (create/delete), so a later shadowing declaration invalidates cached object-record bindings.Hits read/write the descriptor value directly, mirroring
GlobalObject.SetFromMutableBinding''s existing writable-MutableBinding fast path. The cache fills from both read and write resolutions.Inlining shape was measured, not guessed: a null pre-check on the cache field keeps the cost for non-global identifiers to a single field test, while the validator and the write arm are
NoInlining— inlining them taxed local-variable loops ~7% through code growth inGetValue/AssignToIdentifier.Measurements (new
GlobalAccessBenchmarks, tight same-window A/B)EngineComparison
stopwatchwall-clock: 207.5 → 186.0 ms/iter (−10.4%).Verification
Jint.Tests0 failures (net10.0 + net472), PublicInterface green🤖 Generated with Claude Code