Build small object literals in O(1) with cached keys, no extra allocation#2548
Merged
Merged
Conversation
lahma
force-pushed
the
okojo-ws2-object-literal-keys
branch
from
June 27, 2026 11:10
90f8b0e to
b53fa3a
Compare
lahma
enabled auto-merge (squash)
June 27, 2026 11:11
JintObjectExpression.BuildObjectFast re-derived a Key from each property-name string on every execution, re-running the FNV hash per property per object. Pre-compute the Key[] once at construction (when _canBuildFast) and index it in the build loop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (WS-2 stage B) BuildObjectFast inserted each property through the PropertyDictionary indexer, and for the list backing (<9 properties) that re-walked the chain from the head on every insert — an O(n^2) build. For the common case (literal evaluated outside any generator/async frame, with statically distinct keys) link the property nodes directly with a local tail cursor instead: each append is O(1), the pre-hashed keys are reused, suspension bookkeeping is skipped, and the node chain is adopted by a list-backed PropertyDictionary that allocates exactly what the indexer path did — there is no retained tail pointer, so per-object allocation is unchanged. Literals with duplicate keys, >= 9 properties, or evaluated inside a generator fall through to the existing checked path. PropertyAllocBenchmark (default job, .NET 10, same-runtime A/B): Literal3 -3.3%, Literal8 -6.5%, allocation flat (95.21 MB / 159.92 MB unchanged); constructor shapes unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lahma
force-pushed
the
okojo-ws2-object-literal-keys
branch
from
June 28, 2026 10:24
b53fa3a to
4854a4d
Compare
lahma
added a commit
that referenced
this pull request
Jun 28, 2026
…2552) 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 #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. Claude-Session: https://claude.ai/code/session_01RDS23QeSSNRkaUB2KL74U9 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jul 6, 2026
This was referenced Jul 13, 2026
This was referenced Jul 20, 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
Object literals are built by
JintObjectExpression.BuildObjectFast, which inserted each property through thePropertyDictionaryindexer. For the list backing (literals with fewer than 9 properties) that re-walked the chain from the head to find the tail on every insert, making the build O(n²), and it re-derived (and re-hashed) aKeyfrom each property-name string on every execution.Two changes:
Key[]on the node (computed once when the literal is fast-buildable), so every execution skips re-hashing the property names.PropertyDictionary. Because the tail is a local (no retained_tailfield onListDictionary), per-object allocation is exactly what it was before.Literals with duplicate keys, 9+ properties, or evaluated inside a generator fall through to the existing checked path (which already builds a dictionary in O(1) for the 9+ case). The adopted list keeps
checkExistingKeys: trueso any post-construction property add still de-duplicates, matching the previous behaviour; it costs nothing because the build never uses the add path.Benchmark
Jint.Benchmark/PropertyAllocBenchmarkandObjectAccessBenchmark, default BDN job, .NET 10, Ryzen 9 5950X, same-runtime worktree A/B (baseline = upstream/main, run back-to-back, two A/B pairs):The benefit of the O(1) build grows with property count: the 8-property literal shows a clear, repeatable improvement, while the 3-property build is already so cheap that the change sits within run-to-run variance (baseline Literal3 itself ranged ~47–50 ms across runs). Allocation is deterministically unchanged everywhere — the build allocates exactly what it did before.
Correctness
dotnet test Jint.Tests: 3137 passed, 0 failed.dotnet test Jint.Tests.PublicInterface: 79 passed, 0 failed.{1:..,'1':..}), integer-key enumeration order, the 8-property list edge and 9+ dictionary cutover, deletion (head/middle/tail), generators that build object literals, and ordered value side effects.🤖 Generated with Claude Code