Create the constructor .prototype lazily for ordinary functions#2512
Merged
Conversation
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
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 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.
What
Every ordinary function declaration/expression eagerly built its
.prototypeobject (anObjectInstanceWithConstructor+ itsconstructordescriptor + the prototype descriptor) viaMakeConstructor— even when the function is never used as a constructor and its.prototypeis never read.This replaces the eager prototype with a memoizing
LazyPropertyDescriptor<Function>that materializes the prototype on first access (new F(), or readingf.prototype)._value, sof.prototype === f.prototypeandnew F().__proto__ === f.prototype.Object.keys/getOwnPropertyNamesstill listprototypein the same position without reading (materializing) its value.writable/enumerable:false/configurable:false) match the eager descriptor.writable:false), generators/async (explicit prototype), and all built-in constructors (set_prototypeDescriptordirectly, not viaMakeConstructor).Why
linq-js declares hundreds of helper functions and
new-es essentially none — each was allocating 3 heap objects for a.prototypenobody reads.Benchmarks (new
FunctionDeclarationAllocBenchmark, default job, stash A/B)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-stressetc.) is flat — those don't declare many user functions.Tests
🤖 Generated with Claude Code