Skip to content

Add object method-call fast path reusing the member inline cache#2510

Merged
lahma merged 2 commits into
sebastienros:mainfrom
lahma:perf/method-call-fast-path
Jun 9, 2026
Merged

Add object method-call fast path reusing the member inline cache#2510
lahma merged 2 commits into
sebastienros:mainfrom
lahma:perf/method-call-fast-path

Conversation

@lahma

@lahma lahma commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

What

obj.method() / this.method() where the receiver is a plain identifier or this and the property is a literal name now resolves the callee and the this binding through the member expression's existing version-gated own-property inline cache (JintMemberExpression.GetCalleeForCall), avoiding the per-call Reference rent and the property re-resolution the call path previously paid on every invocation.

Previously the callee went through JintExpression.EvaluateJintMemberExpression.EvaluateInternal, which rents a Reference, and then Engine.GetValue re-resolved the property. The version-gated inline cache only lived in the GetValue override, which the call path never reached. This routes the common eligible shape through that same cache.

Non-callable results and every other callee shape (computed, optional, super, private names, custom reference resolver, primitive base) fall through to the unchanged Reference path. The identifier/this receiver is side-effect-free, so that fallback never double-evaluates anything observable.

Why

The stopwatch EngineComparison row and OO-heavy JS generally spend a large fraction of time in method-call dispatch. This removes a per-call Reference rent + a property dictionary lookup.

Benchmarks (new MethodCallBenchmark, default job, stash A/B in one window)

Method Baseline This PR Δ
MethodCallThis 381.9 ms 343.7 ms −10.0%
MethodCallCaptured 372.1 ms 340.3 ms −8.5%
FreeFunctionCall (guard) 358.5 ms 362.6 ms +1.1% (StdDev overlaps → noise)

Allocated flat at 68.36 MB across all three.

Guard scripts (wall-clock driver, 3-run medians) — flat:

Guard Baseline This PR
dromaeo-object-array 24.49 ms/iter 23.96 ms/iter
array-stress 6.95 ms/iter 6.86 ms/iter

Tests

  • Jint.Tests net10.0: 3067 passed, 0 failed
  • Jint.Tests net472: 3005 passed, 0 failed
  • Test262: 99260 passed, 0 failed

🤖 Generated with Claude Code

lahma and others added 2 commits June 8, 2026 21:55
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>
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>
@lahma
lahma merged commit 07821c4 into sebastienros:main Jun 9, 2026
4 checks passed
@lahma
lahma deleted the perf/method-call-fast-path branch June 9, 2026 09:48
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>

---------

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>

---------

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>
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