diff --git a/Jint/Native/Function/ScriptFunction.cs b/Jint/Native/Function/ScriptFunction.cs index 4420d87ad..101e8579f 100644 --- a/Jint/Native/Function/ScriptFunction.cs +++ b/Jint/Native/Function/ScriptFunction.cs @@ -17,6 +17,18 @@ public sealed class ScriptFunction : Function, IConstructor internal List? _privateMethods; internal List? _fields; + // Allocation-site feedback for shaping `new T()` instances. A constructor's first + // CtorShapePromoteThreshold instances build dictionaries (so a constructor called once or twice — the + // overwhelming norm, e.g. across the Test262 suite — never grows the shared per-prototype transition + // tree); once it proves "hot" it is promoted to shape mode so repeated `new T()` with a stable layout + // reuse one interned hidden class. _ctorEmptyShape caches the prototype's empty root to avoid a + // per-construct lookup (revalidated when .prototype is reassigned). + private const int CtorShapePromoteThreshold = 16; + private bool _ctorShaped; + private int _ctorSampleCount; + private Shape? _ctorEmptyShape; + private ObjectInstance? _ctorEmptyShapeProto; + /// /// http://www.ecma-international.org/ecma-262/5.1/#sec-13.2 /// @@ -202,6 +214,26 @@ ObjectInstance IConstructor.Construct(JsCallArguments arguments, JsValue newTarg static intrinsics => intrinsics.Object.PrototypeObject, static (Engine engine, Realm _, object? _) => new JsObject(engine)); } + + // Once the constructor is hot, start each fresh `this` in shape-building mode so this.x= / + // class fields transition a shared interned hidden class instead of building a dictionary. + // Cold constructors (below the promote threshold) stay on the dictionary path. + if (thisArgument is JsObject thisObject && thisObject.Prototype is { } proto) + { + if (_ctorShaped) + { + if (!ReferenceEquals(_ctorEmptyShapeProto, proto)) + { + _ctorEmptyShape = _engine.GetEmptyShape(proto); + _ctorEmptyShapeProto = proto; + } + thisObject.StartShapeBuilding(_ctorEmptyShape!); + } + else if (++_ctorSampleCount >= CtorShapePromoteThreshold) + { + _ctorShaped = true; + } + } } ref readonly var calleeContext = ref PrepareForOrdinaryCall(newTarget); diff --git a/Jint/Native/JsObject.cs b/Jint/Native/JsObject.cs index 7055a4fab..f6389f00b 100644 --- a/Jint/Native/JsObject.cs +++ b/Jint/Native/JsObject.cs @@ -78,6 +78,52 @@ internal void SetSlot(int slot, JsValue value) internal void ClearShape() { _store = null; - _type &= ~InternalTypes.ShapeMode; + _type &= ~(InternalTypes.ShapeMode | InternalTypes.ShapeBuilding); + } + + // store[0] is the shape; values start at [1], so a fresh builder has room for 3 properties before regrow. + private const int InitialBuildCapacity = 4; + + /// + /// Starts incremental shape mode for a still-empty object (a constructor's this): installs the + /// prototype's empty root shape and a small growable store, so subsequent this.x= adds transition + /// the shape (interned, shared across new T() instances) instead of building a dictionary. + /// + internal void StartShapeBuilding(Shape emptyRoot) + { + var store = new object[InitialBuildCapacity]; + store[0] = emptyRoot; + _store = store; + _type |= InternalTypes.ShapeMode | InternalTypes.ShapeBuilding; + unchecked { _propertiesVersion++; } + } + + /// + /// Adds a brand-new configurable+enumerable+writable data property to a shape-mode object by + /// transitioning to the interned child shape and growing the store. Returns false — leaving the + /// object untouched — when a megamorphic guard trips (too many own properties, or this shape already + /// has too many distinct child transitions), so the caller deopts to the dictionary representation. The + /// key must be known-absent (callers check). + /// + internal bool TryShapeAdd(in Key key, JsValue value) + { + var store = _store!; + var shape = Unsafe.As(store[0]); + if (shape.SlotCount >= Shape.MaxShapeProperties || shape.TransitionCount >= Shape.MaxFanout) + { + return false; + } + + var valueIndex = shape.SlotCount + 1; // store[0] is the shape; the new value goes at SlotCount + 1 + if (store.Length <= valueIndex) + { + System.Array.Resize(ref store, store.Length * 2); + _store = store; + } + + store[valueIndex] = value; + store[0] = shape.Add(key); + unchecked { _propertiesVersion++; } + return true; } } diff --git a/Jint/Native/Object/ObjectInstance.cs b/Jint/Native/Object/ObjectInstance.cs index b7b2a7c91..c50abec3c 100644 --- a/Jint/Native/Object/ObjectInstance.cs +++ b/Jint/Native/Object/ObjectInstance.cs @@ -1627,10 +1627,15 @@ public bool CreateDataProperty(JsValue p, JsValue v) return true; } - // Brand-new property added to an already-shaped object (e.g. an Object.assign / spread - // target, or assigning a new key to a literal). Object literals build their shape up front - // via JsObject.SetStore, so shape mode is never grown incrementally one property at a time; - // such adds fall back to the dictionary representation. + // Brand-new property: a hot constructor's `this` (ShapeBuilding) grows its shape via an + // interned transition shared across instances. Plain shaped objects (literals) lack the flag + // and fall through to deopt, since a one-off literal gaining a key is not a reused layout. + // The megamorphic guard inside TryShapeAdd also deopts object-as-hashmap usage. + if ((_type & InternalTypes.ShapeBuilding) != InternalTypes.Empty && jo.TryShapeAdd(key, v)) + { + return true; + } + ConvertToDictionaryMode(); SetOwnProperty(p, new PropertyDescriptor(v, PropertyFlag.ConfigurableEnumerableWritable)); return true; diff --git a/Jint/Native/Object/Shape.cs b/Jint/Native/Object/Shape.cs index 9e2f15715..f0b0cb469 100644 --- a/Jint/Native/Object/Shape.cs +++ b/Jint/Native/Object/Shape.cs @@ -30,6 +30,13 @@ internal sealed class Shape // At or above it, a lazily-built key->slot index is used instead. private const int LinearScanLimit = 16; + // Megamorphic guards for incremental shape growth (constructor this.x= via TryShapeAdd). An object + // that grows past MaxShapeProperties, or a shape that sprouts more than MaxFanout distinct child + // transitions (the object-used-as-a-hashmap pattern), deopts to the dictionary representation so the + // shared per-prototype transition tree cannot grow without bound. Ordinary constructors never hit these. + internal const int MaxShapeProperties = 64; + internal const int MaxFanout = 64; + /// Parent shape (one fewer property), or null for the empty root shape. internal readonly Shape? Parent; @@ -39,6 +46,9 @@ internal sealed class Shape /// Number of own string properties (= slot count). The added property lives at slot SlotCount - 1. internal readonly int SlotCount; + /// Number of distinct add-property transitions out of this shape (fan-out). + internal int TransitionCount => _transitions?.Count ?? 0; + // Add-property transitions: key -> child shape. Lazily allocated; this is what interns layouts. private Dictionary? _transitions; diff --git a/Jint/Runtime/InternalTypes.cs b/Jint/Runtime/InternalTypes.cs index 422ee2213..47db06585 100644 --- a/Jint/Runtime/InternalTypes.cs +++ b/Jint/Runtime/InternalTypes.cs @@ -38,7 +38,11 @@ internal enum InternalTypes // dictionary mode. Lets the hot property paths discriminate shape vs dictionary storage with a single // flag test on the already-loaded _type instead of a `this is JsObject` type-check plus a field read. ShapeMode = 65536, + // a shape-mode JsObject that is still being built up incrementally (a hot constructor's `this`), so a + // brand-new property grows the shape via a transition. Plain shaped objects (object literals) lack this + // flag and deopt to a dictionary when they gain a key, since they aren't a reused allocation site. + ShapeBuilding = 131072, Primitive = Boolean | String | Number | Integer | BigInt | Symbol, - InternalFlags = ObjectEnvironmentRecord | RequiresCloning | PlainObject | Array | Module | IsHTMLDDA | ShapeMode + InternalFlags = ObjectEnvironmentRecord | RequiresCloning | PlainObject | Array | Module | IsHTMLDDA | ShapeMode | ShapeBuilding }