Skip to content

Build small object literals in O(1) with cached keys, no extra allocation#2548

Merged
lahma merged 2 commits into
sebastienros:mainfrom
lahma:okojo-ws2-object-literal-keys
Jun 28, 2026
Merged

Build small object literals in O(1) with cached keys, no extra allocation#2548
lahma merged 2 commits into
sebastienros:mainfrom
lahma:okojo-ws2-object-literal-keys

Conversation

@lahma

@lahma lahma commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

Object literals are built by JintObjectExpression.BuildObjectFast, which inserted each property through the PropertyDictionary indexer. 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) a Key from each property-name string on every execution.

Two changes:

  1. Cache the pre-hashed Key[] on the node (computed once when the literal is fast-buildable), so every execution skips re-hashing the property names.
  2. Build distinct small literals in O(1) with no extra allocation. For the common case — a literal evaluated outside any generator/async frame (so no property value can suspend the build) with statically distinct keys and fewer than 9 properties — link the property nodes directly with a local tail cursor instead of going through the indexer. Each append is O(1), suspension bookkeeping is skipped, and the finished chain is adopted by a list-backed PropertyDictionary. Because the tail is a local (no retained _tail field on ListDictionary), 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: true so 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/PropertyAllocBenchmark and ObjectAccessBenchmark, default BDN job, .NET 10, Ryzen 9 5950X, same-runtime worktree A/B (baseline = upstream/main, run back-to-back, two A/B pairs):

Benchmark Baseline This PR Δ time Allocated
Literal8 (8 props) 64.8–65.5 ms 57.0–60.5 ms −6% … −13% 159.92 MB (flat)
Literal3 (3 props) 47.3–50.3 ms ~48 ms within noise 95.21 MB (flat)
UpdateObjectProperty 90.50 ms 88.35 ms −2.4% 60.43 MB (flat)
WriteObjectProperty 67.35 ms 67.30 ms flat 30.22 MB (flat)
Constructor3 / Constructor1 within noise flat

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.
  • Test262 (strict + sloppy): 0 new failures.
  • Covered by ad-hoc checks: distinct/duplicate/colliding keys ({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

@lahma
lahma force-pushed the okojo-ws2-object-literal-keys branch from 90f8b0e to b53fa3a Compare June 27, 2026 11:10
@lahma
lahma enabled auto-merge (squash) June 27, 2026 11:11
lahma and others added 2 commits June 28, 2026 13:23
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
lahma force-pushed the okojo-ws2-object-literal-keys branch from b53fa3a to 4854a4d Compare June 28, 2026 10:24
@lahma
lahma merged commit 8119d15 into sebastienros:main Jun 28, 2026
4 checks passed
@lahma
lahma deleted the okojo-ws2-object-literal-keys branch June 28, 2026 12:31
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>
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