Skip to content

Add version-gated inline cache for global variable bindings#2507

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:global-binding-cache
Jun 8, 2026
Merged

Add version-gated inline cache for global variable bindings#2507
lahma merged 1 commit into
sebastienros:mainfrom
lahma:global-binding-cache

Conversation

@lahma

@lahma lahma commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator

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 — the stopwatch comparison benchmark shape — pays this hundreds of thousands of times per run.

What

JintIdentifierExpression now 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 writable MutableBinding data property on the real GlobalObject with no global lexical declaration shadowing it. Validity:

  • the global object''s own-property shape version (_propertiesVersion — bumped on add/remove/redefine, not on value writes), and
  • a new GlobalEnvironment._lexicalMutations counter, 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 in GetValue/AssignToIdentifier.

Measurements (new GlobalAccessBenchmarks, tight same-window A/B)

Benchmark Before After
GlobalVarLoop (200k iterations, 3 reads + 3 writes each) 58.40 ms 43.26 ms (−25.9%)
LocalVarLoop (fixed-slot guard) 53.20 ms 50.89 ms (clean)

EngineComparison stopwatch wall-clock: 207.5 → 186.0 ms/iter (−10.4%).

Verification

  • Jint.Tests 0 failures (net10.0 + net472), PublicInterface green
  • Test262: 99,208 passed / 0 failed (incl. the global let/const shadowing, delete, redefine and TDZ suites that exercise the invalidation paths)

🤖 Generated with Claude Code

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
lahma force-pushed the global-binding-cache branch from 1393cca to 93531f5 Compare June 8, 2026 13:35
@lahma
lahma merged commit 32af29b into sebastienros:main Jun 8, 2026
4 checks passed
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>
@lahma
lahma deleted the global-binding-cache 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