Hidden-class shapes for object literals (−50/72% literal allocation)#2552
Merged
Conversation
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
This was referenced Jun 28, 2026
This was referenced Jul 6, 2026
This was referenced Jul 13, 2026
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.
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 aJsObject+ one small array referencing a sharedShape, instead of aHybridDictionary+ NPropertyDescriptors.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
Literal3{a,b,c}Literal8Constructor3Constructor1UpdateObjectPropertyWriteObjectPropertyEngineComparison —
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 everyJsObjectnow carries (the unshapednew 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 viaEngine.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.JsObject(the plain-object type), not baseObjectInstance, soJsDate/JsArray/JsTypedArray/ interop wrappers / built-ins keep their size. A singleobject[] _storeholds theShapeat[0]and slot values at[1..]→ +8 B perJsObject(one reference), and allocation-neutral for shaped objects (the object saves a field, the array grows one element).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.JintMemberExpression, keyed on the immutableShapeso it hits across all objects of a layout — a fresh literal no longer misses. No version counter needed on this path.InternalTypes.ShapeModeflag discriminates shape vs dictionary on the already-loaded_type;Unsafe.As/Unsafe.Addon the proven-shaped hot path (bounds-checked fallback for net462 / netstandard).SlotPropertyDescriptorflyweight keepsGetOwnProperty/ enumeration / spread /JSONworking via the existingCustomValueindirection (slow paths only).Conformance & tests
Jint.Tests: 3138 (net10.0) / 3076 (net472), 0 failures.Jint.Tests.PublicInterface: 79/79 — no public API change (shapes are internal).Notes
ConvertToDictionaryModebuilt the fallback dictionary withcheckExistingKeys: falseand never re-enabled it, so re-setting a key on a deopted object (e.g.Object.definePropertyturning a literal's data property into an accessor) appended a duplicate.this.x=incremental adds) were prototyped and reverted: they win repeated-shape construction but regress diverse one-off objects (extraShape/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