Skip to content

Probe own-property existence/enumerability without materializing descriptors#2585

Merged
lahma merged 2 commits into
sebastienros:mainfrom
lahma:perf-own-property-probe
Jul 6, 2026
Merged

Probe own-property existence/enumerability without materializing descriptors#2585
lahma merged 2 commits into
sebastienros:mainfrom
lahma:perf-own-property-probe

Conversation

@lahma

@lahma lahma commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Read-only callers that only need "does this own property exist, and is it enumerable" previously went through GetOwnProperty, which has two allocation problems on hot enumeration paths:

  • shape-mode objects allocate a fresh SlotPropertyDescriptor on every call (hasOwnProperty, propertyIsEnumerable, for-in, Object.keys/values/entries, spread, Object.assign, JSON.stringify all hit this per key — the handlebars extend() pattern conjures three of them per key per call);
  • dense arrays allocate one CEW descriptor per touched index and grow a permanent _sparse shadow dictionary beside _dense (TryGetDescriptor(createIfMissing: true)), so merely enumerating a dense array bloats it for life.

This adds an internal tri-state probe and converts the read-only callers:

  • ObjectInstance.ProbeOwnProperty(JsValue) returns Missing/NonEnumerable/Enumerable. The base implementation answers shape-mode objects straight from the shape (shape slots are always configurable/enumerable/writable — anything else deopts to dictionary mode); everything else routes through the virtual GetOwnProperty, so exotics keep exact semantics — proxy getOwnPropertyDescriptor traps still fire per key during for-in/Object.keys, typed arrays and interop wrappers are untouched.
  • ArrayInstance overrides the probe to answer dense/sparse elements allocation-free with no _sparse materialization; a previously materialized _sparse descriptor (freeze/defineProperty flows) stays authoritative for its flags. GetOwnProperty/TryGetDescriptor remain byte-for-byte identical for every boundary that needs a real descriptor.
  • Converted callers: HasOwnProperty, the own-property arm of HasProperty, for-in key enumeration (GetKeys), EnumerableOwnProperties (Object.keys/values/entries), CopyDataProperties (object spread / Object.assign), the Object.defineProperties props filter, Object.prototype.hasOwnProperty / propertyIsEnumerable, and the JSON serializer's key filter.

Measurements

Allocation census (--profile-alloc-types, fresh engine per iteration — fresh arrays per round, so first-enumeration materialization is visible):

workload main PR
Object.keys/Object.assign over fresh dense arrays 80.96 MB/iter 29.74 MB/iter (−63%; the Dictionary<uint,PropertyDescriptor> entry arrays disappear from the profile)
for-in over fresh dense arrays 116.6 MB/iter 78.2 MB/iter (−33%)
handlebars-style extend() over object literals 111.8 MB/iter 107.2 MB/iter (−4%, SlotPropertyDescriptor eliminated from the profile)

BenchmarkDotNet default jobs (A/B vs main from separate worktrees; note these suites reuse arrays across iterations, so they mostly show the steady-state time win rather than the first-enumeration allocations):

case main PR delta
ArrayHoleyKeysBenchmark Gap=1 9.32 µs 7.49 µs −19.6%
ArrayHoleyKeysBenchmark Gap=100 / 1000 108.3 / 546.3 µs 102.8 / 541.1 µs −5.1% / −1.0%
JsonBenchmark Parse twitter / bestbuy 2.56 ms / 240.1 ms 2.48 ms / 236.9 ms −3.2% / −1.4%
ObjectEnumerationBenchmark Entries / KeysFiltered 394.0 / 187.5 µs 383.3 / 184.6 µs −2.7% / −1.5%
PreparedScriptBenchmark (prepared modes) 40.2 / 41.3 ms 39.9 / 39.7 ms −0.6% / −3.9%

No regressions; PreparedScript source mode (parse-dominated) moved within its error bars.

Considered and deferred: hoisting the loop-invariant transient descriptors in SetIntegrityLevel — needs a ValidateAndApplyPropertyDescriptor retention audit first, and freeze/seal is cold.

Verification

  • Jint.Tests green (net10.0 + net472), Jint.Tests.PublicInterface green
  • Full test262: 99,260 passed, 0 failed (frozen/sealed array writes, Proxy ownKeys/getOwnPropertyDescriptor trap ordering, for-in semantics all covered)

🤖 Generated with Claude Code

…riptors

Read-only callers that only need "does this own property exist and is it
enumerable" previously went through GetOwnProperty, which on shape-mode
objects allocates a fresh SlotPropertyDescriptor per call, and on dense
arrays allocates one CEW descriptor per touched index AND grows a permanent
_sparse shadow dictionary beside _dense.

New internal ObjectInstance.ProbeOwnProperty returns a tri-state
(Missing/NonEnumerable/Enumerable):

- base implementation answers shape-mode objects from the shape (slots are
  always configurable/enumerable/writable; anything else deopts), everything
  else routes through the virtual GetOwnProperty so exotics — proxies (traps
  still fire per key), typed arrays, wrappers — keep exact semantics;
- ArrayInstance override answers dense/sparse elements allocation-free; a
  previously materialized _sparse descriptor stays authoritative for flags;
- converted callers: HasOwnProperty, HasProperty own-arm, for-in GetKeys,
  EnumerableOwnProperties (Object.keys/values/entries), CopyDataProperties
  (spread), Object.assign, Object.defineProperties filter,
  Object.prototype.hasOwnProperty/propertyIsEnumerable, JSON serializer.

GetOwnProperty and TryGetDescriptor stay byte-for-byte identical for every
spec/API boundary that needs a real descriptor.

Census (AllocTypeProbe): Object.keys/assign over fresh dense arrays
-63% bytes/iter; for-in over dense arrays -33%; handlebars-style
extend() -4% with SlotPropertyDescriptor eliminated from the profile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lahma
lahma enabled auto-merge (squash) July 6, 2026 13:44
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