Skip to content

Create the constructor .prototype lazily for ordinary functions#2512

Merged
lahma merged 5 commits into
sebastienros:mainfrom
lahma:perf/lazy-function-prototype
Jun 9, 2026
Merged

Create the constructor .prototype lazily for ordinary functions#2512
lahma merged 5 commits into
sebastienros:mainfrom
lahma:perf/lazy-function-prototype

Conversation

@lahma

@lahma lahma commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #2511 (which is stacked on #2510). The diff includes those PRs until they merge; review/merge in order (rebase on merge).

What

Every ordinary function declaration/expression eagerly built its .prototype object (an ObjectInstanceWithConstructor + its constructor descriptor + the prototype descriptor) via MakeConstructor — even when the function is never used as a constructor and its .prototype is never read.

This replaces the eager prototype with a memoizing LazyPropertyDescriptor<Function> that materializes the prototype on first access (new F(), or reading f.prototype).

  • Identity stable: memoized in _value, so f.prototype === f.prototype and new F().__proto__ === f.prototype.
  • Enumeration order preserved: the descriptor stays non-null, so Object.keys/getOwnPropertyNames still list prototype in the same position without reading (materializing) its value.
  • Attribute flags (writable/enumerable:false/configurable:false) match the eager descriptor.
  • Unaffected: classes (passed an explicit prototype, writable:false), generators/async (explicit prototype), and all built-in constructors (set _prototypeDescriptor directly, not via MakeConstructor).

Why

linq-js declares hundreds of helper functions and new-es essentially none — each was allocating 3 heap objects for a .prototype nobody reads.

Benchmarks (new FunctionDeclarationAllocBenchmark, default job, stash A/B)

Benchmark Baseline This PR Δ
DeclareMany — Allocated 209.4 KB 162.6 KB −22.4%
linq-js prepared — Allocated 192.9 KB 178.4 KB −7.5%
linq-js prepared — time 59.9 µs 57.2 µs −4.4%
minimal 17.29 KB 17.29 KB unchanged

Cost: the lazy descriptor carries two extra fields, so a function that is used as a constructor allocates ~16 B more (ConstructEach +1.8% allocated). The fixed guard set (array-stress etc.) is flat — those don't declare many user functions.

Tests

  • Jint.Tests net10 (3067) + net472 (3005)
  • Jint.Tests.PublicInterface net10 (79) + net472 (79)
  • Test262: 99260 passed, 0 failed

🤖 Generated with Claude Code

lahma and others added 4 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>
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 sebastienros#2510.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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 sebastienros#2511.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lahma
lahma merged commit 6c892ca into sebastienros:main Jun 9, 2026
7 of 8 checks passed
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 perf/lazy-function-prototype 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