Skip to content

Hidden-class shapes for object literals (−50/72% literal allocation)#2552

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:hidden-class-shapes
Jun 28, 2026
Merged

Hidden-class shapes for object literals (−50/72% literal allocation)#2552
lahma merged 1 commit into
sebastienros:mainfrom
lahma:hidden-class-shapes

Conversation

@lahma

@lahma lahma commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adopt V8/SpiderMonkey-style hidden classes (shapes) for plain object literals: objects of identical layout share one immutable Shape (property name → slot index), and per-instance values live in a flat store. A { a, b, c } literal now allocates a JsObject + one small array referencing a shared Shape, instead of a HybridDictionary + N PropertyDescriptors.

Benchmarks

Default BenchmarkDotNet job, vs the branch base, AMD Ryzen 9 5950X / .NET 10 (allocation is deterministic; time deltas are matched same-machine A/B).

PropertyAllocBenchmark / ObjectAccessBenchmark

Benchmark Time Allocation
Literal3 {a,b,c} 48.6 → 38.0 ms (−24%) 95.2 → 47.9 MB (−49.7%)
Literal8 57.4 → 40.7 ms (−29%) 159.9 → 44.0 MB (−72.5%)
Constructor3 135.6 → 136.0 ms (parity) 134.9 → 136.4 MB (+1.1%)
Constructor1 81.7 → 79.4 ms (parity) 83.6 → 85.2 MB (+1.8%)
UpdateObjectProperty 90.1 → 89.5 ms (parity) no change
WriteObjectProperty 67.8 → 67.7 ms (parity) no change

EngineComparison — Jint_ParsedScript (19 real-world scripts)

Allocation flat to baseline; time flat-to-better — e.g. stopwatch −6.6%, dromaeo-object-regexp −3.2%, dromaeo-object-string-modern −2.1%, array-stress −1.0%; a few scripts +1–3% within run-to-run noise.

The +1–2% on Constructor* is the single +8 B reference every JsObject now carries (the unshaped new T() this); it flips to a win once constructors are shaped (see Notes).

Design

  • Shape (Jint/Native/Object/Shape.cs): immutable transition tree, interned per prototype via Engine.GetEmptyShape, so all objects of a layout under a prototype share their whole chain. Parent-walk lookup for small objects, lazy hash index for wide ones.
  • Shape storage is localized to JsObject (the plain-object type), not base ObjectInstance, so JsDate / JsArray / JsTypedArray / interop wrappers / built-ins keep their size. A single object[] _store holds the Shape at [0] and slot values at [1..]+8 B per JsObject (one reference), and allocation-neutral for shaped objects (the object saves a field, the array grows one element).
  • Object literals build/reuse a cached shared Shape (reusing the pre-hashed keys from Build small object literals in O(1) with cached keys, no extra allocation #2548) and fill a flat store.
  • Shape-keyed inline cache (read / member-call / write) in JintMemberExpression, keyed on the immutable Shape so it hits across all objects of a layout — a fresh literal no longer misses. No version counter needed on this path.
  • InternalTypes.ShapeMode flag discriminates shape vs dictionary on the already-loaded _type; Unsafe.As / Unsafe.Add on the proven-shaped hot path (bounds-checked fallback for net462 / netstandard).
  • SlotPropertyDescriptor flyweight keeps GetOwnProperty / enumeration / spread / JSON working via the existing CustomValue indirection (slow paths only).
  • Anything a shape can't represent (delete, accessor / non-CEW define, freeze/seal, prototype change, bulk install) deopts to the unchanged dictionary path, so spec correctness is bounded by the existing code.

Conformance & tests

  • Test262: 0 new failures (99,260 passed).
  • Jint.Tests: 3138 (net10.0) / 3076 (net472), 0 failures.
  • Jint.Tests.PublicInterface: 79/79 — no public API change (shapes are internal).

Notes

  • Fixes a latent deopt bug: ConvertToDictionaryMode built the fallback dictionary with checkExistingKeys: false and never re-enabled it, so re-setting a key on a deopted object (e.g. Object.defineProperty turning a literal's data property into an accessor) appended a duplicate.
  • Shape constructors (this.x= incremental adds) were prototyped and reverted: they win repeated-shape construction but regress diverse one-off objects (extra Shape/transition allocation with no reuse), which the Test262 suite surfaced as GC-pressure timeouts. They need allocation-site reuse feedback first — left as a follow-up, along with polymorphic / prototype-chain ICs and generated built-in shapes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RDS23QeSSNRkaUB2KL74U9

Replace the per-object dictionary-of-descriptors storage for plain object
*literals* with V8/SpiderMonkey-style hidden classes: objects of identical
layout share one immutable Shape (property name -> slot index), and per-instance
values live in a flat store. A `{a,b,c}` literal now allocates a JsObject + one
small array referencing a shared Shape, instead of a HybridDictionary + N
PropertyDescriptors.

Design
- Shape (Jint/Native/Object/Shape.cs): immutable transition tree, interned per
  prototype via Engine.GetEmptyShape, so all `{a,b}` objects under a prototype
  share their whole chain. Parent-walk lookup for small objects, lazy hash index
  for wide ones.
- Shape storage is localized to JsObject (the plain-object type), not base
  ObjectInstance, so JsDate/JsArray/TypedArray/wrappers keep their size. A single
  `object[] _store` holds the Shape at [0] and slot values at [1..] => +8 B per
  JsObject (one reference), allocation-neutral for shaped objects.
- Object literals build/reuse a cached shared Shape (the sebastienros#2548 pre-hashed keys
  feed it) and fill a flat store (BuildObjectFastShapeBacked).
- Shape-keyed inline cache (read / member-call / write) in JintMemberExpression,
  keyed on the immutable Shape, so it hits across all objects of a layout -- a
  fresh literal no longer misses. No version counter needed on this path.
- InternalTypes.ShapeMode flag discriminates shape vs dictionary on the
  already-loaded _type; Unsafe.As/Unsafe.Add on the proven-shaped hot path.
- SlotPropertyDescriptor flyweight keeps GetOwnProperty / enumeration / spread /
  JSON working via the existing CustomValue indirection (slow paths only).
- Anything a shape can't represent (delete, accessor/non-CEW define, freeze/seal,
  prototype change, bulk install) deopts to the unchanged dictionary path.

Benchmarks (BenchmarkDotNet default job, vs baseline, AMD 5950X / .NET 10)
  PropertyAllocBenchmark      time                     allocation
  Literal3 {a,b,c}    48.6 -> 38.0 ms  (-24%)   95.2 -> 47.9 MB  (-49.7%)
  Literal8            57.4 -> 40.7 ms  (-29%)  159.9 -> 44.0 MB  (-72.5%)
  Constructor3        parity                   134.9 -> 136.4 MB (+1.1%)
  Constructor1        parity (-2.7%)            83.6 -> 85.2 MB  (+1.8%)
  ObjectAccessBenchmark
  Update/Write        parity                   no change
  EngineComparison (19 scripts): allocation flat to baseline; time
  flat-to-better (stopwatch -6.6%, object-regexp -3.2%, array-stress -1.0%;
  a few +1..3%).
The +1-2% Constructor allocation is the single +8 B field on the (unshaped)
`new T()` this; it flips to a win once constructors are shaped (future work).

Conformance: Test262 0 new failures (99,260 passed); Jint.Tests 3138/3076;
Jint.Tests.PublicInterface 79/79 (no public API change).

Notes
- Fixes a deopt bug: ConvertToDictionaryMode built the fallback dictionary with
  checkExistingKeys=false and never re-enabled it, so re-setting a key on a
  deopted object appended a duplicate.
- Shape constructors (this.x= incremental adds) were tried and reverted: they win
  repeated-shape construction but regress diverse one-off objects (extra
  Shape/transition allocation with no reuse), which the Test262 suite surfaced as
  GC-pressure timeouts. Needs allocation-site reuse feedback first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS23QeSSNRkaUB2KL74U9
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