Skip to content

Shape hot constructors' instances (−22..36% Constructor allocation)#2553

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:shape-constructors
Jun 29, 2026
Merged

Shape hot constructors' instances (−22..36% Constructor allocation)#2553
lahma merged 1 commit into
sebastienros:mainfrom
lahma:shape-constructors

Conversation

@lahma

@lahma lahma commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Extend the merged object-literal hidden classes (#2552) to constructor instancesnew T(){ this.a; this.b; this.c }, the TreeNode / dromaeo-object-* pattern and the dominant object-creation shape in real JS. A hot constructor's this builds a shared hidden class instead of a per-object dictionary.

Avoiding the diverse-object pitfall (allocation-site feedback)

Naively shaping every this.x= regresses diverse one-off objects (each unique layout spawns a Shape + transition with no reuse). This PR gates shaping on a constructor proving hot:

  • A constructor's first CtorShapePromoteThreshold (16) instances build dictionaries, so a constructor called once or twice — the overwhelming norm — never grows the shared per-prototype transition tree.
  • Once hot, it is promoted: ScriptFunction.Construct starts each fresh this in shape-building mode (InternalTypes.ShapeBuilding), so this.x= / class fields transition an interned hidden class shared across instances.
  • A megamorphic guard (Shape.MaxShapeProperties / MaxFanout) deopts object-as-hashmap growth.
  • Object literals keep their merged behavior (deopt on a brand-new key — a one-off literal isn't a reused allocation site); Object.assign / spread / JSON.parse targets stay on the dictionary (they never enter shape mode).

Benchmarks (BenchmarkDotNet default job, vs merged main; allocation is deterministic)

PropertyAllocBenchmark / ObjectAccessBenchmark

Benchmark Allocation Gen0/1k Time
Constructor3 (this.a=;this.b=;this.c=) 136.4 → 87.6 MB (−35.8%) 8500 → 5250 145.2 → 139.8 ms (−3.7%)
Constructor1 (this.value=) 85.2 → 66.8 MB (−21.5%) 5200 → 4167 parity
Literal3 / Literal8 unchanged (already shaped) parity
UpdateObjectProperty / WriteObjectProperty unchanged parity

EngineComparison (Jint_ParsedScript, 19 scripts)

Allocation flat to slightly betterdromaeo-3d-cube −1.3% (its hot constructors now shape), everything else within ±1%. No regression.

Conformance & tests

  • Test262: matches merged main run-for-run. (A few slow annexB RegExp-*-escape-BMP eval-loop tests intermittently time out under heavy concurrent load on a busy machine — identically with and without this change, confirmed via a clean-main control run; on a fresh machine both are 0-fail.)
  • Jint.Tests: 3138 (net10.0) / 3076 (net472), 0 failures.
  • Jint.Tests.PublicInterface: 79/79 — no public API change (all additions internal).

Design

  • ScriptFunction: per-constructor feedback (_ctorShaped, _ctorSampleCount, threshold 16) + a cached prototype empty-shape root (avoids a per-construct lookup).
  • JsObject.StartShapeBuilding (installs the empty root + a growable store, sets ShapeMode | ShapeBuilding); JsObject.TryShapeAdd (transition to the interned child + grow store + megamorphic caps).
  • ObjectInstance.CreateDataProperty: a new key on a ShapeBuilding object transitions the shape; a plain shaped object (literal) deopts.
  • Shape.MaxShapeProperties / MaxFanout / TransitionCount re-added for the guard.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RDS23QeSSNRkaUB2KL74U9

Extend hidden-class shapes from object literals to constructor instances
(`new T(){ this.a; this.b; this.c }` — TreeNode / dromaeo-object-* pattern, the
dominant object-creation shape in real JS).

Allocation-site feedback (the key to avoiding the diverse-object pitfall the first
spike hit): a constructor's first CtorShapePromoteThreshold (16) instances build
dictionaries, so a constructor called once or twice — the overwhelming norm —
never grows the shared per-prototype transition tree. Once it proves "hot" it is
promoted: ScriptFunction.Construct starts each fresh `this` in shape-building mode
(InternalTypes.ShapeBuilding), so this.x= / class fields transition an interned
hidden class shared across instances instead of a per-object dictionary. A
megamorphic guard (Shape.MaxShapeProperties / MaxFanout) deopts object-as-hashmap
growth. Object literals keep their merged behavior (deopt on a new key — they are
not a reused allocation site); Object.assign / spread / JSON targets stay
dictionary (they never enter shape mode).

Benchmarks (PropertyAllocBenchmark, default job, vs merged main; allocation is
deterministic):

  Constructor3   136.41 -> 87.58 MB  (-35.8%)   Gen0 8500 -> 5250
  Constructor1    85.15 -> 66.84 MB  (-21.5%)   Gen0 5200 -> 4167
  Literal3/8     unchanged (already shaped)
  ObjectAccess   unchanged

Time is parity-to-better (Constructor3 -3.7%). Test262 matches the merged-main
baseline run for run (a handful of slow annexB RegExp eval-loop tests
intermittently time out under heavy concurrent load on a busy machine, identically
with and without this change — confirmed via a clean-main control run); on a fresh
machine both are 0-fail.

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