| title | Object Shapes Design |
|---|---|
| description | Hidden-class (object shapes) design for the JS engine — implemented. |
Object shapes (hidden classes): design + phased migration plan
Status: IMPLEMENTED (2026-07-12) — Phases 0–3 landed on this branch; every phase gated on the full test262 language+built-ins baseline with zero regressions (47,291 tests). See §6.5 for the deltas between this design and what actually landed (slots hold whole
Propertyvalues so attribute edges never demote; transition edges areWeak; the per-shapeindexis anFxIndexMapforEquivalent-probe parity).Original design note (2026-07-06): this answers the question
js-performance-roadmap.md§3.7 deferred — "revisit with fresh callgrind data; ifget_index_ofstill dominates property-heavy code, shapes are the next step" — with fresh data, and it comes to a more nuanced conclusion: hashing is no longer the headline cost; per-object construction is. Shapes are still the right structural answer, but their value here is structure sharing (allocation + key interning + enumeration for free) more than lookup acceleration, and the phasing below is chosen accordingly.The three invariants are unchanged: zero
unsafe, no new heavyweight dependencies, byte-identical deterministic replay. Shapes are derived purely from insertion order and property attributes, are never serialized, and never influence observable enumeration order — the determinism story is identical to the inline caches'.
json_roundtrip (20k stringify+parse round-trips of a 5-key record with
nested objects/arrays), 1.20 G instructions total:
| cost center | Ir share | what it is |
|---|---|---|
malloc/free/memcpy family |
~25% | per-object IndexMap tables, per-key strings, Vecs, Value boxes |
IndexMap::get_index_of + insert_full |
~8.4% | key hashing on parse-insert and stringify/get lookups |
json_stringify + json_quote_into |
~7.7% | inherent serialization work |
JsonParser::parse_* |
~6.2% | inherent parsing work |
Vm::get_from_object + get_prop + step |
~11% | generic property walks + interpreter dispatch |
own_keys + enumerable_own_keys_excluding |
~3.2% | stringify enumeration (clones every key per object per pass) |
Two conclusions:
- The kernel tier already ate the lookup problem where it matters.
Monomorphic property loops (
property_access) run as register programs with entry-resolved slots (kernel prop localization);get_index_ofno longer dominates any loop-shaped workload. What's left of it lives in cold structure — one lookup per key per object during parse/stringify, IC-verified interpreter accesses outside loops. - Construction is the bottleneck shapes actually fix. Every
JSON.parseobject allocates its own hash table and (until the Phase-0 interning below) its own copies of key strings that every sibling record spells identically; every{x, y}literal in a loop does the same. Structure sharing turns "N same-shape objects" into "one shared shape + N slot vectors."
Phase 0 (landed with this doc): interning parse keys by source slice cut
~4% of json_roundtrip — confirming keys were a real but minor slice, and
that the per-object table churn is the dominant remainder.
ObjectData today:
pub struct ObjectData {
pub props: FxIndexMap<PropertyKey, Property>, // insertion-ordered
pub internal: Internal, // exotic payloads
pub proto: Option<JsObject>,
pub extensible: bool,
}props is public and directly manipulated at 309 sites across the
engine (.props field accesses: gets, get_full/get_index[_mut],
inserts, shift_remove, iteration, contains_key). The inline caches and
the kernel prop machinery both already exploit IndexMap's stable
insertion-order slot indices — that is, the engine has organically grown
half a shape system: slot-indexed access with identity verified per use.
What's missing is the sharable identity (one pointer comparison instead of
key equality checks) and the shared storage (no per-object table).
A big-bang replacement of props is therefore not the plan. The plan is to
make the existing map cheap to have for shaped objects, by splitting the
key→slot index (shared, in the shape) from the slot values (per-object).
/// One node in the shape tree. Immutable once created; shared via Rc.
pub struct Shape {
/// Parent shape (None = the empty root for a given birth proto).
parent: Option<Rc<Shape>>,
/// The property this node appends to the parent, and its attributes.
/// Attribute CHANGES (defineProperty) demote to dictionary mode rather
/// than fork attribute-variant shape chains (rare; keeps the tree small).
key: PropertyKey,
enumerable: bool,
// writable/configurable: shaped properties are always plain data
// properties with default attributes; anything else demotes (§3.4).
/// Slot index of `key` in the owning object's slot vector (== depth-1).
slot: u32,
/// key → child shape for the next appended property.
/// Lazily allocated; most shapes have 0 or 1 transition.
transitions: RefCell<FxHashMap<PropertyKey, Rc<Shape>>>,
/// key → slot for O(1) lookup once the chain grows past a threshold;
/// below it, lookup walks the parent chain (shorter than a hash for the
/// 2-5 key objects that dominate real code). Built lazily per shape.
index: OnceCell<FxHashMap<PropertyKey, u32>>,
}Object storage becomes an enum behind accessors:
pub enum PropStorage {
/// Plain data properties with default attributes, insertion-ordered:
/// the shape holds the keys, `slots[i]` holds the value for the
/// property at chain depth i.
Shaped { shape: Rc<Shape>, slots: Vec<Value> },
/// Everything else: accessors, non-default attributes, deleted keys,
/// symbol-keyed exotics mid-mutation — today's map, verbatim.
Dict(FxIndexMap<PropertyKey, Property>),
}- Enumeration order is insertion order in both modes (shape chain order == insertion order by construction; spec integer-key ordering is applied at enumeration time exactly as today). Mode is unobservable.
- Replay determinism: shapes are derived from program behavior only
(keys, insertion order, attribute operations). No addresses, no RNG. The
transition tree is per-
Vm(rooted inRealm), so cross-realm sharing never happens and identity checks stay realm-local — same policy as the proto-identity ICs. - A shaped object's slot indices are stable for its lifetime in that shape — append-only transitions; any destructive change demotes.
- Construction: object literals compile to "reserve slots, walk the
transition chain once per site" — after warm-up the chain walk is N
pointer hops with no hashing and ONE allocation (the slot vec).
JSON.parsegets the same via a per-parser cursor cache: record shapes repeat, so each record after the first isVec::with_capacity(n)+ n value writes. - ICs: today's key-verified ICs upgrade to (shape ptr, slot) —
verification is one
Rc::ptr_eqinstead of a key compare + map probe. Misses re-resolve exactly as now; no invalidation protocol is added (same "verify on every use" discipline). - Kernel prop localization: entry resolution becomes a shape-chain walk
(or
indexprobe) instead ofget_full; the per-activation invariants are unchanged. Slot vec replacesget_index_mutin the write-back. own_keys/stringify: the key list is the shape chain — no per-object key cloning for the common all-enumerable case.
delete, defineProperty with any non-default attribute or accessor,
preventExtensions/seal/freeze (reify then mark), index-keyed
properties on non-arrays past a small bound, and proto mutation do NOT
demote (shape is keyed by birth proto only for the root; proto lives on
ObjectData as today and IC/kernel guards already verify it). Demotion
materializes the map from the chain once; objects never re-promote.
Internalexotics (arrays' dense storage, typed arrays, proxies) — all already bypasspropson their fast paths.- The array kernel ops, dense-element machinery, prototype-chain guards.
- The journal/replay surface: nothing shape-related is recorded.
- Phase 0 — parse-key interning (landed with this doc): JSON object
keys interned by source slice; ~4% on
json_roundtrip, zero structural risk. - Phase 1 — accessor extraction: replace direct
.propsfield access with a narrowObjectDataAPI (own_get,own_get_full,own_insert,own_remove,own_iter,own_index/get_index_mutfor the IC/kernel paths). Pure mechanical refactor of the 309 sites, zero behavior change, landable in slices. This is the bulk of the diff and de-risks everything after it. - Phase 2 —
PropStoragebehind the API: introduce the enum withDictas the only constructor (still zero behavior change), then flip plain-object birth toShapedwith demotion triggers per §3.4. Differential coverage: the kernels corpus + a new shapes-focused corpus (delete/defineProperty/freeze mid-loop, enumeration order, proxies). - Phase 3 — consumers: IC upgrade to (shape, slot); kernel prop entry
resolution via shape; literal-site transition caching;
JSON.parserecord-shape cursor; stringify keys-from-shape. - Phase 4 — measurement + demotion tuning: re-profile json/closures/
property-heavy workloads; tune the chain-walk vs
indexthreshold and the literal-site cache.
Expected wins (from the profile shares): json_roundtrip construction+lookup slices total ~35% of its instructions today; shapes address most of the map/key allocation and hashing within it — a 1.3–1.6× on json is realistic, plus across-the-board gains on object-literal-heavy code (closures workload allocates a record per iteration) and colder property access.
- The 309-site refactor is where mistakes hide. Mitigation: Phase 1 is semantically inert and mechanical; land it in reviewable slices, each gated on the full test262 baseline (the gate caught two real bugs during the kernel work on this branch; it is the effective spec oracle).
- Engines grow their worst bugs in transitions (
delete, redefinition, proto swaps). Mitigation: demote on every destructive edge — the dictionary path IS today's battle-tested code; shapes only ever serve the append-only common case. - Memory: transition trees can retain dead shapes. Per-
Vmrooting + the existing cycle collector's registry covers reclamation at quiescence; shapes hold noValues, only keys. u128liveness / slot bounds: slot vecs cap at the same bound as dictionary properties today (no new limit).
The landed implementation follows §3–§4 with four refinements, each chosen to shrink the correctness surface rather than to chase speed:
- Slots hold whole
Propertyvalues (Shaped { shape, slots: Vec<Property> }), not bareValues, and the shape does NOT carry anenumerablebit. The shape therefore encodes exactly one thing — the insertion-ordered key list — and every attribute/kind mutation (definePropertywith any attributes, accessors,seal/freeze) works identically in both modes with NO demotion. The §3.4 demotion set shrinks to the order-destroying edges only:deleteof a present key, and integer-index keys past a small bound (8). This also let the Phase-1 accessor API keepIndexMap-shaped signatures (&Property/&mut Property, 3-tupleget_full), which is what made the ~340-site refactor mechanical. The cost issize_of::<Property>()per slot instead ofsize_of::<Value>()— the allocation-count win (one slot vec vs. a per-objectIndexMap+ key strings) is unchanged. - Transition edges are
Weak(transitions: RefCell<FxHashMap<PropertyKey, Weak<Shape>>>); the child holds its parent strongly. The §3.1 sketch's strong child edges would form parent↔childRccycles that the reference-counting GC can never reclaim. With weak edges a shape subtree dies with its last object / cache entry, and a later transition simply rebuilds the node — no registry, no §5 quiescence sweep needed. - The per-shape
indexis anFxIndexMap<PropertyKey, u32>(not a plainFxHashMap) so it accepts the sameEquivalent<PropertyKey>probes (StrKeyRef) as the dictionary path. - ICs hold
Rc<Shape>strongly in the newIcEntry::{own_shape, proto_shape}fields — one small key chain pinned per monomorphic site, in exchange for upgrade-free verification. The JSON.parse cursor is a per-nesting-depth path (Vec<Rc<Shape>>per depth) so nested records don't clobber their parent's cursor mid-object.
Measured (callgrind instruction counts, examples/shapebench, vs the
fork point efb16a0): object_literals −19%, mixed_helpers −5.8%,
for-in over per-iteration records −3.8%, JSON.parse −1.9%,
json_roundtrip −0.8%, JSON.stringify +1.7% — of which ~+0.5M
instructions in every workload is one-time realm setup (intrinsic
container objects now mint shape chains; ≈0.15 ms per Engine::new, and
two rounds of tuning already halved it twice: an inline single-entry
transition slot, and two-touch arming of the per-shape key index so
grow-by-insert singletons never pay O(n²) index builds). The §4 1.3–1.6×
json estimate did NOT materialize on this corpus: with Phase-0 interning
and the pre-reserved parse maps already landed, per-record construction
was a smaller slice of json_roundtrip than the 2026-07-06 table
suggested, and stringify/parse inherent work dominates. The structural
wins concentrate where construction actually dominates (object literals
in loops, record-processing helpers).
Phase-4 items still open: stringify-from-shape (serializing slots
directly for all-plain-data records — deliberately skipped because the
spec's per-key re-lookup during serialization is observable under
mutating toJSON), and shrinking the residual realm-setup delta if
Engine::new latency ever matters.
Independent. Register bytecode removes interpreter dispatch/operand-stack costs; shapes remove per-object storage costs. The kernel tier's typed-slot experience (entry-resolved indices, verify-per-use, decline-to-generic) is the proven pattern both reuse. Shapes first: they also make the register-bytecode ICs cheaper when that lands.