Skip to content

Prototype-method inline cache for obj.method resolved on the direct prototype#2558

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:shape-proto-fastpath
Jun 30, 2026
Merged

Prototype-method inline cache for obj.method resolved on the direct prototype#2558
lahma merged 1 commit into
sebastienros:mainfrom
lahma:shape-proto-fastpath

Conversation

@lahma

@lahma lahma commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Member reads and member-calls whose property lives on the receiver's direct prototype
arr.push(...), date.getTime(), counter.inc() where inc is on the prototype — had no inline
cache. Only own properties were cached, so every such access fell through the own-property fast
paths and re-walked the prototype chain: a dictionary miss-probe on the receiver, then a full
recursive Get on the prototype (its own fast-path check + dictionary hit-probe + descriptor unwrap).

Add a monomorphic prototype-method cache to JintMemberExpression, shared by the read and
member-call sites (a member node serves one role and reads one property name). On a hit it returns
the cached prototype descriptor's value directly. Validity is correct-by-construction:

  • same receiver instance (ReferenceEquals) — per-site monomorphic;
  • receiver own-shape unchanged (_propertiesVersion) — no own property added that would shadow;
  • direct prototype unchanged (GetPrototypeOf() ReferenceEquals the cached holder) — not re-pointed;
  • holder own-shape unchanged (_propertiesVersion) — method not redefined or deleted.

The prototype is derived through the virtual GetPrototypeOf() (exactly as ObjectInstance.Get
walks it), so objects that shadow the _prototype field and override [[GetPrototypeOf]] — e.g.
custom interop instances — resolve to the correct prototype.

The populate path no longer re-probes the receiver's own property: the read/member-call fast paths
already established the own-miss (a shape-slot miss, or a GetOwnProperty that returned undefined),
and thread that through as ownMissConfirmed, so a never-hitting (polymorphic-by-identity) site does
one own-property probe per call instead of two.

Receivers and prototypes with a non-ordinary [[Get]] for named keys must never be bypassed. Add
InternalTypes.ExoticGet, set in the constructors of the ObjectInstance subclasses whose [[Get]]
resolves named keys specially — JsProxy, JsTypedArray, IteratorResult, ObjectWrapper
(and its ArrayLikeWrapper subclass, which inherits the flag), NamespaceReference, ModuleNamespace
— and skip the cache when either the receiver or its direct prototype carries it. ArrayInstance also
overrides Get, but only for integer indices and length (both of which it reports from
GetOwnProperty, so the populate path defers to the full Get there); it is ordinary for named keys
and intentionally stays unflagged, which is what keeps arr.push / arr.pop cacheable.
PrototypeMethodCacheTests includes a reflection tripwire that fails if a new Get override appears
unclassified, plus behavioural tests for shadowing, redefinition, deletion, prototype reassignment,
and accessor re-invocation after the cache is warm.

Benchmarks (default jobs, same machine, baseline upstream/main d6e3da8, same thermal window):

MethodCallBenchmark — the dedicated, multi-iteration signal:

Method Baseline Branch Delta
ArrayPushPop (arr.push/pop loop) 246.4 ms 189.0 ms -23.3%
UserPrototypeMethod (c.inc on proto) 343.0 ms 294.8 ms -14.1%
MethodCallThis (guard) 291.8 ms 280.5 ms -3.9%
MethodCallCaptured (guard) 297.2 ms 305.6 ms +2.8%
FreeFunctionCall (guard) 337.0 ms 345.8 ms +2.6%
Guards exercise own-method / free-function paths the cache never reaches; they sit in the
+/-3-4% run-to-run noise. Allocation is unchanged (this is a read-path speedup).

EngineComparison (Jint, prepared scripts; averaged over two same-window baseline/treatment pairings):
consistent wins on array/object-heavy scripts — array-stress -12.3%, dromaeo-object-array-modern
-9.7%. The remaining scripts fall within this machine's ~+/-3-4% process-to-process noise (their
deltas sign-flip across repeated pairings); dromaeo-object-string, which a redundant own-property
probe initially nudged ~+2%, is flat after the ownMissConfirmed change.

Test262: no new failures (Failed: 4 / Passed: 99256, identical to baseline d6e3da8; the 4 annexB
RegExp failures are pre-existing 65536-iteration eval + RegExp-compile loops that flaky-time-out
independent of this change). Jint.Tests (3145/0), Jint.Tests.PublicInterface, Jint.Tests.CommonScripts,
Jint.Tests.SourceGenerators all green.


🤖 Generated with Claude Code

https://claude.ai/code/session_01RDS23QeSSNRkaUB2KL74U9

… prototype

Member reads and member-calls whose property lives on the receiver's *direct prototype* —
`arr.push(...)`, `date.getTime()`, `counter.inc()` where `inc` is on the prototype — had no inline
cache. Only *own* properties were cached, so every such access fell through the own-property fast
paths and re-walked the prototype chain: a dictionary miss-probe on the receiver, then a full
recursive `Get` on the prototype (its own fast-path check + dictionary hit-probe + descriptor unwrap).

Add a monomorphic prototype-method cache to `JintMemberExpression`, shared by the read and
member-call sites (a member node serves one role and reads one property name). On a hit it returns
the cached prototype descriptor's value directly. Validity is correct-by-construction:

  - same receiver instance (`ReferenceEquals`) — per-site monomorphic;
  - receiver own-shape unchanged (`_propertiesVersion`) — no own property added that would shadow;
  - direct prototype unchanged (`GetPrototypeOf()` `ReferenceEquals` the cached holder) — not re-pointed;
  - holder own-shape unchanged (`_propertiesVersion`) — method not redefined or deleted.

The prototype is derived through the virtual `GetPrototypeOf()` (exactly as `ObjectInstance.Get`
walks it), so objects that shadow the `_prototype` field and override `[[GetPrototypeOf]]` — e.g.
custom interop instances — resolve to the correct prototype.

The populate path no longer re-probes the receiver's own property: the read/member-call fast paths
already established the own-miss (a shape-slot miss, or a `GetOwnProperty` that returned undefined),
and thread that through as `ownMissConfirmed`, so a never-hitting (polymorphic-by-identity) site does
one own-property probe per call instead of two.

Receivers and prototypes with a non-ordinary `[[Get]]` for *named* keys must never be bypassed. Add
`InternalTypes.ExoticGet`, set in the constructors of the `ObjectInstance` subclasses whose `[[Get]]`
resolves named keys specially — `JsProxy`, `JsTypedArray`, `IteratorResult`, `ObjectWrapper`
(and its `ArrayLikeWrapper` subclass, which inherits the flag), `NamespaceReference`, `ModuleNamespace`
— and skip the cache when either the receiver or its direct prototype carries it. `ArrayInstance` also
overrides `Get`, but only for integer indices and `length` (both of which it reports from
`GetOwnProperty`, so the populate path defers to the full `Get` there); it is ordinary for named keys
and intentionally stays unflagged, which is what keeps `arr.push` / `arr.pop` cacheable.
`PrototypeMethodCacheTests` includes a reflection tripwire that fails if a new `Get` override appears
unclassified, plus behavioural tests for shadowing, redefinition, deletion, prototype reassignment,
and accessor re-invocation after the cache is warm.

Benchmarks (default jobs, same machine, baseline upstream/main d6e3da8, same thermal window):

MethodCallBenchmark — the dedicated, multi-iteration signal:
  | Method                              | Baseline | Branch   | Delta  |
  |-------------------------------------|----------|----------|--------|
  | ArrayPushPop   (arr.push/pop loop)  | 246.4 ms | 189.0 ms | -23.3% |
  | UserPrototypeMethod (c.inc on proto)| 343.0 ms | 294.8 ms | -14.1% |
  | MethodCallThis      (guard)         | 291.8 ms | 280.5 ms |  -3.9% |
  | MethodCallCaptured  (guard)         | 297.2 ms | 305.6 ms |  +2.8% |
  | FreeFunctionCall    (guard)         | 337.0 ms | 345.8 ms |  +2.6% |
  Guards exercise own-method / free-function paths the cache never reaches; they sit in the
  +/-3-4% run-to-run noise. Allocation is unchanged (this is a read-path speedup).

EngineComparison (Jint, prepared scripts; averaged over two same-window baseline/treatment pairings):
  consistent wins on array/object-heavy scripts — array-stress -12.3%, dromaeo-object-array-modern
  -9.7%. The remaining scripts fall within this machine's ~+/-3-4% process-to-process noise (their
  deltas sign-flip across repeated pairings); dromaeo-object-string, which a redundant own-property
  probe initially nudged ~+2%, is flat after the ownMissConfirmed change.

Test262: no new failures (Failed: 4 / Passed: 99256, identical to baseline d6e3da8; the 4 annexB
RegExp failures are pre-existing 65536-iteration eval + RegExp-compile loops that flaky-time-out
independent of this change). Jint.Tests (3145/0), Jint.Tests.PublicInterface, Jint.Tests.CommonScripts,
Jint.Tests.SourceGenerators all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS23QeSSNRkaUB2KL74U9
@lahma
lahma merged commit 3c42a26 into sebastienros:main Jun 30, 2026
4 checks passed
@lahma
lahma deleted the shape-proto-fastpath branch June 30, 2026 07:51
lahma added a commit that referenced this pull request Jun 30, 2026
…location, not two) (#2559)

A shape-mode `JsObject` kept its hidden-class shape and slot values in a single combined `object[]`
store (`[shape, v0, v1, …]`), so constructing one allocated two objects: the `JsObject` and the store
array. This makes the slot values *in-object*: `JsObject` now holds the shape reference and the first
`InlineCapacity` (4) slot values in fields (via an `[InlineArray]` block on modern runtimes, hand-rolled
fixed fields on legacy TFMs), spilling only the surplus of larger layouts into an overflow array. A
constructor or object literal whose instance fits the in-object capacity — the overwhelming majority of
real objects: points, pairs, nodes, small records — now allocates a **single object**, and
`StartShapeBuilding` (a constructor's `this`) allocates no slot storage at all until `this.x=` fills a
slot. Reads also drop the pointer-chase: a slot below the inline capacity is a field-offset access, not
a load of the store array followed by an index.

All access goes through the existing `ShapeOf`/`GetSlot`/`SetSlot` seam, so the change is contained to
`JsObject` plus the literal builder (which fills slots directly instead of building an array). Larger
objects (> in-object capacity) keep an overflow array and are allocation-neutral; dictionary-mode
objects are unaffected functionally and pay only the wider inline-slot fields.

Benchmarks (default jobs, same machine, baseline #2558 / 3c42a26):

  PropertyAlloc — construction (cached field values isolate per-object cost):
    | Benchmark           | Baseline          | Branch            | Delta            |
    |---------------------|-------------------|-------------------|------------------|
    | Constructor3Cached  | 133.2 ms / 69.9 MB| 122.9 ms / 66.9 MB| -7.7% / -4.4%, Gen0 -5.9% |
    | Literal2Cached      |  38.5 ms / 28.7 MB|  38.0 ms / 27.2 MB| -1.3% / -5.3%    |
    | Constructor1 (mixed)|        69.9 MB    |        66.9 MB    | -4.6% alloc/time |
    | Literal3 (mixed)    |        50.2 MB    |        47.0 MB    | -6.4% alloc      |

  ObjectAccess (2-run average):
    | UpdateObjectProperty (read+write) | -1.7%  (inline read skips the array pointer-chase) |
    | WriteObjectProperty  (pure write) | +2.2%  (the inline/overflow branch in SetSlot)      |

  EngineComparison (Jint_ParsedScript, 19 scripts): allocation-neutral (-0.4%..+0.5%), time-flat.

The win is construction allocation (one fewer object per small constructed object) plus a small read
speed-up; the cost is a ~2% slow-down on a *pure*-write microbenchmark (the inline/overflow branch in
`SetSlot`, offset by the read speed-up in any read+write pattern) and a wider per-`JsObject` footprint.
EngineComparison (real-world mixed scripts) is allocation-neutral and time-flat, so neither cost shows
up outside the microbenchmarks.

Test262: 0 failures / 99260 passed (unchanged from baseline). Jint.Tests, Jint.Tests.PublicInterface,
Jint.Tests.CommonScripts, Jint.Tests.SourceGenerators all green.


Claude-Session: https://claude.ai/code/session_01RDS23QeSSNRkaUB2KL74U9

Co-authored-by: Claude Opus 4.8 <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