From 9d5a014cde6c2a3572f5871d4a6b6573a63b9bbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 03:42:47 +0000 Subject: [PATCH 1/4] fix(runtime): Object.create births its result ordinary, so store sites publish its shape Since #11166 an Object.create result is class-less (class_id 0) and nothing marked it ordinary, so the static-key store site's receiver-kind test refused it: every o.k = v on such a receiver missed the site cache and took the full [[Set]] walk through js_put_value_set_packed_miss, even for an own data property. The acceptance matrix's ocreate column moved ~1,300 instr/op (overwrite 293 -> 1,588). OrdinaryObjectCreate yields an ordinary object whose [[Prototype]] is a fact of its shape (#11342), so it is born ordinary like the other ordinary birth sites, and the store site publishes its ShapeId as for a literal or a class instance. Regression test: the site word is primed from an Object.create receiver and the emitted hit's receiver-kind half admits it (fails with the mark removed). --- changelog.d/object-create-ordinary-birth.md | 9 ++++ .../src/object/object_ops/prototype.rs | 12 +++++- .../src/proxy/put_value/packed_set_tests.rs | 41 +++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 changelog.d/object-create-ordinary-birth.md diff --git a/changelog.d/object-create-ordinary-birth.md b/changelog.d/object-create-ordinary-birth.md new file mode 100644 index 0000000000..ed6f2802b0 --- /dev/null +++ b/changelog.d/object-create-ordinary-birth.md @@ -0,0 +1,9 @@ +Fixed a ~1,300 instructions-per-store regression on `Object.create(proto)` +receivers. Since `Object.create` stopped minting a synthetic class id per call +(#11166), its result is class-less, and nothing marked it an ordinary object, so +the static-key store site's receiver-kind test refused it: every `o.k = v` +missed the site cache and took the full `[[Set]]` walk, even for an own data +property. `Object.create` now births its result ordinary, like the other +ordinary birth sites, and the store site publishes its shape as it does for a +literal or a class instance. On the acceptance matrix the `Object.create` +overwrite cell goes from 1,588 back to about 300 instructions per store. diff --git a/crates/perry-runtime/src/object/object_ops/prototype.rs b/crates/perry-runtime/src/object/object_ops/prototype.rs index 7107dd4fd6..806fd57cde 100644 --- a/crates/perry-runtime/src/object/object_ops/prototype.rs +++ b/crates/perry-runtime/src/object/object_ops/prototype.rs @@ -82,7 +82,17 @@ pub extern "C" fn js_object_create(proto_value: f64) -> f64 { } let scope = crate::gc::RuntimeHandleScope::new(); let proto = scope.root_nanbox_f64(proto_value); - let obj = scope.root_raw_mut_ptr(js_object_alloc(0, 0)); + let born = js_object_alloc(0, 0); + // `OrdinaryObjectCreate(proto)`: the result is an ORDINARY object, and its + // [[Prototype]] becomes a fact of its shape in the link below (#11342). + // So it is born ordinary like every other ordinary birth site + // (`mark_object_plain_ordinary`): the store sites' receiver-kind test then + // admits it on its ShapeId alone, exactly as it admits a literal or a + // class instance. Unmarked, a class-less receiver fails that test on every + // store and takes the full `[[Set]]` walk (#11166 moved Object.create off + // its synthetic class id, which had been admitting it). + unsafe { crate::object::mark_object_plain_ordinary(born) }; + let obj = scope.root_raw_mut_ptr(born); // The link is a self-rooting entry point: it roots the owner and the // prototype before its meta-record allocation, so the handle is re-read // afterwards for the post-collection address. diff --git a/crates/perry-runtime/src/proxy/put_value/packed_set_tests.rs b/crates/perry-runtime/src/proxy/put_value/packed_set_tests.rs index 7a714912d0..b100344d16 100644 --- a/crates/perry-runtime/src/proxy/put_value/packed_set_tests.rs +++ b/crates/perry-runtime/src/proxy/put_value/packed_set_tests.rs @@ -516,3 +516,44 @@ fn a_dictionary_receiver_never_publishes_a_store_site_word() { "a dictionary receiver must not publish" ); } + +/// An `Object.create(proto)` receiver is an ORDINARY object whose prototype is +/// a fact of its shape (#11342), so a store site publishes its shape exactly +/// as it publishes a literal's or a JSON object's. #11166 moved Object.create +/// off its synthetic class id onto `class_id == 0`; unmarked, the receiver +/// failed the receiver-kind test on every store and took the full `[[Set]]` +/// walk (the acceptance matrix's ocreate column went 293 -> 1,588 instr/op). +#[test] +fn an_object_create_receiver_publishes_its_shape_and_inline_slot() { + let proto = parsed(br#"{"pa":32}"#); + let target = crate::object::js_object_create(proto); + let obj = object_of(target); + assert_eq!( + unsafe { (*obj).class_id }, + 0, + "test premise: Object.create yields a class-less receiver" + ); + let a = interned(b"a"); + let b = interned(b"b"); + // Both keys land in the birth-floor inline slots (the key-adds are what + // the fixture's `t.a = 1; t.b = 2` performs). + store_fresh(target, a, 1.0); + store_fresh(target, b, 2.0); + let (stored, word) = store_fresh(target, b, 5.0); + assert_eq!(stored, 5.0, "the miss performs the store"); + assert_eq!( + word as u32, + stamp(target), + "an Object.create receiver must publish its ShapeId to the site word" + ); + assert_eq!(word >> 32, 1, "high half: `b` is the second own slot"); + // The emitted hit's per-object half admits it too, so the published + // word is actually served inline rather than missing on every store. + assert!( + unsafe { packed_hit_receiver_ok(obj) }, + "the emitted hit's receiver-kind test must admit an Object.create receiver" + ); + // Its prototype is still the one it was created with. + let got = crate::object::js_object_get_prototype_of(target); + assert_eq!(got.to_bits(), proto.to_bits()); +} From aae811e82b8acba0b4369416f2f78d50e3d9f3f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 11:46:57 +0000 Subject: [PATCH 2/4] perf(runtime): an Object.create birth is allocated as wide as its birth shape's descendants grow (#10905) Object.create(P) allocated its result at the two-slot floor, so the third own field and every later one lived in overflow storage for the object's whole life: ~220 instructions per spilled store and ~100 per spilled read over an inline one. In-object slack tracking, as a fact of the birth shape. The keyless shape (P, []) that every Object.create(P) result is born on records, in bits its record word already reserved, how wide its descendants grow (the largest key count of any shape minted below it, raised by any descendant that spills) and how many births it served while tracking. The first 8 births get 8 slots (or the learned width, if larger); later births get exactly the learned width, capped at 64, and the old floor when nothing grew. The width is capacity only and is stamped as the birth shape (P, [], width), like a class born wide (#11360). Reads and writes never consult it. The record survives a full prune when a birth asked it during the epoch. --- changelog.d/ocreate-birth-width.md | 15 ++ .../src/object/object_ops/prototype.rs | 23 ++- crates/perry-runtime/src/object/shapes.rs | 27 ++- .../src/object/shapes_birth_width.rs | 189 ++++++++++++++++++ .../src/object/shapes_birth_width_tests.rs | 139 +++++++++++++ .../perry-runtime/src/object/shapes_store.rs | 57 +++++- crates/perry-runtime/src/object/spill.rs | 6 + 7 files changed, 445 insertions(+), 11 deletions(-) create mode 100644 changelog.d/ocreate-birth-width.md create mode 100644 crates/perry-runtime/src/object/shapes_birth_width.rs create mode 100644 crates/perry-runtime/src/object/shapes_birth_width_tests.rs diff --git a/changelog.d/ocreate-birth-width.md b/changelog.d/ocreate-birth-width.md new file mode 100644 index 0000000000..aef7503ad6 --- /dev/null +++ b/changelog.d/ocreate-birth-width.md @@ -0,0 +1,15 @@ +perf(runtime): `Object.create(P)` results are allocated as wide as their +descendants grow (#10905). The keyless birth shape `(P, [])` now records how +wide the objects born on it grow — the largest key count of any shape minted +below it, raised by any descendant that spills — and a birth is allocated at +that width (8 slots for the first 8 births while it tracks, then exactly the +learned width, capped at 64, and the old two-slot floor when nothing grew). +Before, every `Object.create` result had two inline slots, so its third own +field and every later one lived in overflow storage for the object's whole +life. On the acceptance matrix (release build, instructions per op over the seven +provenances) the `Object.create` column moves from 264-334 to 48-120 on +`overwrite`, 295-369 to 79-151 on `read1`, 710-810 to 234-326 on `read4`, +452-528 to 79-151 on `addkey` and 525-598 to 311-380 on `inherited`; the +literal column is unchanged (its `addkey` cells are 7 lower). The width is +capacity only (keys stay authoritative) and is never consulted by a read or a +write. diff --git a/crates/perry-runtime/src/object/object_ops/prototype.rs b/crates/perry-runtime/src/object/object_ops/prototype.rs index 806fd57cde..e8f6972a0a 100644 --- a/crates/perry-runtime/src/object/object_ops/prototype.rs +++ b/crates/perry-runtime/src/object/object_ops/prototype.rs @@ -82,7 +82,28 @@ pub extern "C" fn js_object_create(proto_value: f64) -> f64 { } let scope = crate::gc::RuntimeHandleScope::new(); let proto = scope.root_nanbox_f64(proto_value); - let born = js_object_alloc(0, 0); + // #10905: the result is born on the keyless shape `(proto, [])`, and that + // BIRTH shape knows how wide its descendants grow (in-object slack + // tracking, `shapes::shapes_birth_width`), so the object is allocated + // that wide instead of at the two-slot floor its own keys would spill + // past. The shape names the prototype by its serial, which marking + // assigns; the link below marks it too, and a second mark is a no-op. + let birth_width = { + let value = crate::value::JSValue::from_bits(proto.get_nanbox_u64()); + if value.is_pointer() { + // SAFETY: a validated object pointer, rooted by `proto`; the mark + // roots its target across its own allocation. + unsafe { + crate::object::proto_validity::mark_object_as_prototype( + value.as_pointer::() as usize, + ) + } + .map_or(0, crate::object::shapes::keyless_birth_width) + } else { + 0 + } + }; + let born = js_object_alloc(0, birth_width); // `OrdinaryObjectCreate(proto)`: the result is an ORDINARY object, and its // [[Prototype]] becomes a fact of its shape in the link below (#11342). // So it is born ordinary like every other ordinary birth site diff --git a/crates/perry-runtime/src/object/shapes.rs b/crates/perry-runtime/src/object/shapes.rs index 6e433fe4c4..629be3c944 100644 --- a/crates/perry-runtime/src/object/shapes.rs +++ b/crates/perry-runtime/src/object/shapes.rs @@ -34,10 +34,13 @@ use crate::array::ArrayHeader; use std::cell::RefCell; +#[path = "shapes_birth_width.rs"] +mod shapes_birth_width; #[path = "shapes_slot_list.rs"] mod shapes_slot_list; #[path = "shapes_store.rs"] mod shapes_store; +pub(crate) use shapes_birth_width::{keyless_birth_width, note_spill_width}; #[cfg(test)] pub(crate) use shapes_slot_list::shape_descriptor_keys_slot; pub(crate) use shapes_slot_list::shape_id_owns_keys_slot; @@ -48,9 +51,9 @@ pub(crate) use shapes_slot_list::{ try_update_stable_tombstone_shape, try_update_stable_tombstone_shape_cached, SlotIndex, }; use shapes_store::{ - IdList, ShapeRecord, ShapeSlab, RECORD_FLAG_CACHE_CARRIER, RECORD_FLAG_CARRIED_SEEN, - RECORD_FLAG_EXTERNAL_CARRIER, RECORD_FLAG_FACTS_INDEXED, RECORD_FLAG_OLD_CARRIER, - RECORD_FLAG_OLD_CARRIER_SEEN, + IdList, ShapeRecord, ShapeSlab, RECORD_FLAG_BIRTH_OWNER, RECORD_FLAG_CACHE_CARRIER, + RECORD_FLAG_CARRIED_SEEN, RECORD_FLAG_EXTERNAL_CARRIER, RECORD_FLAG_FACTS_INDEXED, + RECORD_FLAG_OLD_CARRIER, RECORD_FLAG_OLD_CARRIER_SEEN, }; #[derive(Clone)] @@ -982,6 +985,17 @@ pub(crate) fn shape_descriptor_ensure_with_holes( // history (see `IdList::append_unchecked`). inner.facts_append_fresh(facts, id); inner.family_append_fresh(keys_id, id); + // #10905: every shape of the transition tree below a keyless birth shape + // is minted exactly once, here, so this is where the birth shape learns + // how wide its descendants grow (`shapes_birth_width`). + if object_kind == ShapeObjectKind::Ordinary && semantic_generation == 0 { + shapes_birth_width::note_descendant_width( + &inner, + table.slab(), + proto_id, + logical_key_count, + ); + } Ok(id) } @@ -1302,6 +1316,7 @@ pub(crate) fn rotate_old_carrier_epoch_after_full_trace() { (*record).set(RECORD_FLAG_OLD_CARRIER, seen); (*record).set(RECORD_FLAG_OLD_CARRIER_SEEN, false); (*record).set(RECORD_FLAG_CARRIED_SEEN, false); + (*record).set(RECORD_FLAG_BIRTH_OWNER, false); } }); } @@ -2927,7 +2942,11 @@ pub(crate) fn prune_uncarried_shape_descriptors_after_full_trace() { table.slab().for_each(|id, record| { // SAFETY: live slab record, read immediately under agent ownership. let record = unsafe { &*record }; - if !record.has(RECORD_FLAG_CARRIED_SEEN) && !record.cache_carrier() { + // #10905: a keyless birth shape an allocation consulted this epoch + // keeps the width it learned, though its births sit on wider shapes. + if !record.has(RECORD_FLAG_CARRIED_SEEN | RECORD_FLAG_BIRTH_OWNER) + && !record.cache_carrier() + { stale.push(id); } }); diff --git a/crates/perry-runtime/src/object/shapes_birth_width.rs b/crates/perry-runtime/src/object/shapes_birth_width.rs new file mode 100644 index 0000000000..177bd97442 --- /dev/null +++ b/crates/perry-runtime/src/object/shapes_birth_width.rs @@ -0,0 +1,189 @@ +//! #10905: in-object slack tracking, owned by the BIRTH shape. +//! +//! An object born with no keys (`Object.create(P)`) used to get the +//! allocator's two-slot floor whatever its program went on to store, so its +//! third own key and every later one lived in overflow storage for the +//! object's whole life (overflow is permanent: nothing moves a spilled value +//! back inline). A spilled store measured ~220 instructions over an inline +//! one and a spilled read ~100. +//! +//! The fix is V8's in-object slack tracking, expressed as a fact of the +//! shape. `Object.create(P)` is born on the keyless shape `(P, [])` — the +//! BIRTH shape of every object created from P, one per prototype because the +//! prototype is part of shape identity (#11342). That record, and nothing +//! else, carries two numbers in bits its word already reserved: +//! +//! * the WIDTH its descendants grow to: the largest key count of any shape +//! minted with prototype P (every shape of the transition tree below the +//! birth shape is minted exactly once, so this is the tree's maximum, which +//! is what V8 computes when tracking completes), raised further by any +//! descendant that spills past its inline slots; +//! * a count of the births served while tracking. +//! +//! The first [`TRACKING_BIRTHS`] births are allocated [`TRACKING_WIDTH`] slots +//! wide (or wider, if the width learned so far is larger), so the objects a +//! program creates first — often the only ones — keep their fields inline. +//! Every later birth is allocated at exactly the learned width, and at the +//! allocator's floor when nothing grew. The width is capacity only: the keys +//! stay authoritative, and a birth at width `w` is stamped with the shape +//! `(P, [], live w)`, the same "(keys, width) birth ShapeId" a class born +//! wide gets (`js_object_shape_id_for_class_keys_live`, #11360). +//! +//! Objects already allocated keep working: a key past their inline slots +//! spills exactly as before, and that spill is what teaches the record. +//! Nothing here is consulted by a read or a write; only the allocation of a +//! keyless birth asks, and only the mint and spill paths teach. +//! +//! Polymorphic growth takes the MAXIMUM: descendants of one birth shape that +//! grow to different widths are all born at the widest, capped at +//! [`LEARNED_WIDTH_MAX`] slots. That is the memory cost, and it is bounded: +//! an object smaller than the maximum carries at most the difference in +//! unused inline slots, against the 16-slot overflow array (plus header) the +//! narrow birth allocates on its first spill. +//! +//! Lifetime: the facts live exactly as long as the record. The record is +//! kept through a full collection when a birth asked it during the epoch +//! before ([`RECORD_FLAG_BIRTH_OWNER`], cleared by the epoch rotation), and +//! pruned like any uncarried shape otherwise — a prototype nobody creates +//! from any more forgets its width and relearns it on its next births. + +use super::shapes_store::{self, ShapeRecord, RECORD_FLAG_BIRTH_OWNER, RECORD_FLAG_FACTS_INDEXED}; +use super::{ShapeObjectKind, ShapeTableInner, PROTO_ID_CLASS, PROTO_ID_DEFAULT}; + +/// How many births of a keyless birth shape are served while tracking. +pub(crate) const TRACKING_BIRTHS: u32 = 8; +/// The width a birth is served while tracking (unless more was learned). +pub(crate) const TRACKING_WIDTH: u32 = 8; +/// The largest learned width a birth is ever served. Also bounds the byte +/// the width is stored in. +pub(crate) const LEARNED_WIDTH_MAX: u32 = 64; + +/// Is `proto_id` a recorded prototype OBJECT's serial — the identity +/// `Object.create(P)` gives its result, and the only band whose keyless +/// birth shape is ever consulted? Excludes the default `Object.prototype` +/// (0: literals), a null prototype, and the class/mixed/unique bands. +#[inline] +pub(super) fn is_prototype_serial(proto_id: u64) -> bool { + proto_id != PROTO_ID_DEFAULT && proto_id < PROTO_ID_CLASS +} + +/// The keyless birth record of `proto_id`, if one is present. Probe only: +/// never mints. +fn find_birth_record( + inner: &ShapeTableInner, + slab: &shapes_store::ShapeSlab, + proto_id: u64, +) -> Option<*mut ShapeRecord> { + let facts = + shapes_store::facts_key_proto(0, 0, 0, 0, ShapeObjectKind::Ordinary, 0, proto_id, 0); + let ids = inner.by_facts.get(&facts)?; + for &id in ids.as_slice() { + let Some(record) = slab.record_ptr(id) else { + continue; + }; + // SAFETY: a live slab record, read immediately on this agent. + let r = unsafe { &*record }; + if r.has(RECORD_FLAG_FACTS_INDEXED) + && r.facts_match_proto(0, 0, 0, 0, ShapeObjectKind::Ordinary, 0, proto_id, 0) + { + return Some(record); + } + } + None +} + +/// Teach the keyless birth record of `proto_id` that a descendant reached +/// `width` inline slots. A no-op when no such record is present. +pub(super) fn note_descendant_width( + inner: &ShapeTableInner, + slab: &shapes_store::ShapeSlab, + proto_id: u64, + width: u32, +) { + if width <= crate::object::INLINE_SLOT_FLOOR as u32 || !is_prototype_serial(proto_id) { + return; + } + if let Some(record) = find_birth_record(inner, slab, proto_id) { + // SAFETY: a live slab record; single-threaded agent. + unsafe { (*record).note_descendant_width(width.min(LEARNED_WIDTH_MAX)) }; + } +} + +/// The inline width to allocate an object born on the keyless birth shape of +/// `proto_id` with, or 0 for the allocator's floor. Mints the birth shape if +/// it is absent (its first birth, or its first after a prune), counts this +/// birth against the tracking window, and keeps the record through the next +/// full collection. +pub(crate) fn keyless_birth_width(proto_id: u64) -> u32 { + if !is_prototype_serial(proto_id) { + return 0; + } + let id = super::publish_shape_result(super::shape_descriptor_ensure_with_generation( + std::ptr::null(), + 0, + 0, + 0, + ShapeObjectKind::Ordinary, + proto_id, + 0, + )); + let table = &crate::state::state().shapes; + let Some(record) = table.slab().record_ptr(id) else { + return 0; + }; + // SAFETY: a live slab record; single-threaded agent. No table borrow is + // held (`shape_descriptor_ensure_with_generation` released it). + let r = unsafe { &mut *record }; + r.set(RECORD_FLAG_BIRTH_OWNER, true); + let learned = r.descendant_width(); + let births = r.tracked_births(); + let width = if births < TRACKING_BIRTHS { + r.set_tracked_births(births + 1); + learned.max(TRACKING_WIDTH) + } else { + learned + }; + if width <= crate::object::INLINE_SLOT_FLOOR as u32 { + 0 + } else { + width.min(LEARNED_WIDTH_MAX) + } +} + +/// A spill at `width` slots on `obj` teaches its keyless birth record, so a +/// lineage whose shapes were all minted before the record existed (a prune +/// in between) still learns from the objects that outgrow it. +/// +/// Out of line and cold: its caller is the spill store, whose in-capacity +/// fast path must not pay for it. +/// +/// # Safety +/// `obj` is a live shaped `ObjectHeader`. +#[cold] +#[inline(never)] +pub(crate) unsafe fn note_spill_width(obj: *const crate::object::ObjectHeader, width: u32) { + if width <= crate::object::INLINE_SLOT_FLOOR as u32 { + return; + } + let table = &crate::state::state().shapes; + let slab = table.slab(); + let Some(record) = slab.record_ptr(super::object_shape_stamp(obj)) else { + return; + }; + let r = &*record; + if r.object_kind() != ShapeObjectKind::Ordinary + || r.semantic_generation != 0 + || !is_prototype_serial(r.proto_id) + { + return; + } + let proto_id = r.proto_id; + let Ok(inner) = table.inner.try_borrow() else { + return; + }; + note_descendant_width(&inner, slab, proto_id, width); +} + +#[cfg(test)] +#[path = "shapes_birth_width_tests.rs"] +mod tests; diff --git a/crates/perry-runtime/src/object/shapes_birth_width_tests.rs b/crates/perry-runtime/src/object/shapes_birth_width_tests.rs new file mode 100644 index 0000000000..0616edd22a --- /dev/null +++ b/crates/perry-runtime/src/object/shapes_birth_width_tests.rs @@ -0,0 +1,139 @@ +//! #10905: an `Object.create(P)` birth is allocated as wide as the +//! descendants of its birth shape `(P, [])` grow, and that width is a fact of +//! the birth shape's record. + +use super::{LEARNED_WIDTH_MAX, TRACKING_BIRTHS, TRACKING_WIDTH}; +use crate::object::ObjectHeader; + +const FIELDS: [&str; 5] = ["bw_a", "bw_b", "bw_c", "bw_e", "bw_d"]; + +fn key(name: &str) -> *const crate::StringHeader { + crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32) +} + +fn boxed(obj: *mut ObjectHeader) -> f64 { + f64::from_bits(crate::value::js_nanbox_pointer(obj as i64).to_bits()) +} + +/// `Object.create(proto)` through the runtime entry compiled code calls. +fn create(proto: *mut ObjectHeader) -> *mut ObjectHeader { + crate::value::js_nanbox_get_pointer(crate::object::js_object_create(boxed(proto))) + as *mut ObjectHeader +} + +fn fill(obj: *mut ObjectHeader, fields: &[&str]) { + for (i, f) in fields.iter().enumerate() { + crate::object::js_object_set_field_by_name(obj, key(f), i as f64); + } +} + +unsafe fn live(obj: *const ObjectHeader) -> u32 { + crate::object::object_live_slot_count(obj) +} + +/// Does `obj` keep any of its values in overflow storage? +unsafe fn spilled(obj: *const ObjectHeader) -> bool { + let meta = (*obj).meta; + !meta.is_null() && (*meta).spill != 0 +} + +/// A fresh prototype: every test gets its own birth shape. +fn prototype() -> *mut ObjectHeader { + let proto = crate::object::js_object_alloc(0, 4); + crate::object::js_object_set_field_by_name(proto, key("bw_inherited"), 6.0); + proto +} + +#[test] +fn object_create_births_are_allocated_as_wide_as_their_descendants_grow() { + let _gc = crate::gc::GcSuppressScope::new(); + unsafe { + let proto = prototype(); + // While the birth shape is tracking, a birth gets the tracking width, + // so the first objects a program creates (often its only ones) keep + // five own fields inline. + for i in 0..TRACKING_BIRTHS { + let o = create(proto); + assert_eq!( + live(o), + TRACKING_WIDTH, + "tracking birth {i} was not served slack" + ); + fill(o, &FIELDS); + assert!( + !spilled(o), + "tracking birth {i} spilled with {} fields", + FIELDS.len() + ); + } + // Tracking is over: every later birth is exactly as wide as the + // descendants grew — the five fields, not the tracking width and not + // the two-slot floor that spilled three of them. + for i in 0..4 { + let o = create(proto); + assert_eq!( + live(o), + FIELDS.len() as u32, + "birth {i} after tracking is not the learned width" + ); + fill(o, &FIELDS); + assert!(!spilled(o), "birth {i} after tracking spilled"); + // The width is capacity only: the keys stay authoritative. + let keys = crate::object::js_object_keys(o); + assert_eq!(crate::array::js_array_length(keys), FIELDS.len() as u32); + let v = crate::object::js_object_get_field_by_name_f64(o, key("bw_d")); + assert_eq!(v, 4.0); + } + } +} + +#[test] +fn a_birth_shape_whose_descendants_never_grow_births_at_the_floor() { + let _gc = crate::gc::GcSuppressScope::new(); + unsafe { + let proto = prototype(); + for _ in 0..TRACKING_BIRTHS { + let o = create(proto); + fill(o, &FIELDS[..1]); + } + let o = create(proto); + assert_eq!( + live(o), + 0, + "nothing grew past the floor, so nothing is reserved" + ); + // And a program that DOES grow later is still correct: the new key + // spills exactly as before, and that spill teaches the birth shape. + fill(o, &FIELDS); + assert!( + spilled(o), + "test premise: a floor birth spills its third key" + ); + let next = create(proto); + assert_eq!( + live(next), + FIELDS.len() as u32, + "the growth did not teach the birth shape" + ); + } +} + +#[test] +fn polymorphic_growth_takes_the_widest_and_is_capped() { + let _gc = crate::gc::GcSuppressScope::new(); + unsafe { + let proto = prototype(); + let names: Vec = (0..LEARNED_WIDTH_MAX + 8) + .map(|i| format!("bw_k{i}")) + .collect(); + let names: Vec<&str> = names.iter().map(String::as_str).collect(); + for i in 0..TRACKING_BIRTHS { + let o = create(proto); + // One lineage grows to 3 keys, another past the cap. + let n = if i == 0 { names.len() } else { 3 }; + fill(o, &names[..n]); + } + let o = create(proto); + assert_eq!(live(o), LEARNED_WIDTH_MAX, "the widest descendant, capped"); + } +} diff --git a/crates/perry-runtime/src/object/shapes_store.rs b/crates/perry-runtime/src/object/shapes_store.rs index 1e147a953b..c79332d3b9 100644 --- a/crates/perry-runtime/src/object/shapes_store.rs +++ b/crates/perry-runtime/src/object/shapes_store.rs @@ -40,9 +40,14 @@ pub(super) const RECORD_FLAG_FACTS_INDEXED: u8 = 1 << 1; pub(super) const RECORD_FLAG_OLD_CARRIER: u8 = 1 << 2; pub(super) const RECORD_FLAG_OLD_CARRIER_SEEN: u8 = 1 << 3; pub(super) const RECORD_FLAG_CACHE_CARRIER: u8 = 1 << 4; -// Bit 5 is FREE: it was `RECORD_FLAG_KIND_CLASS` until the object kind -// became a 2-bit field in `flags_and_kind` (#10868), a flag byte having no -// room for a third value. +// Bit 5 was `RECORD_FLAG_KIND_CLASS` until the object kind became a 2-bit +// field in `flags_and_kind` (#10868), a flag byte having no room for a third +// value. +/// #10905: a keyless birth shape an allocation consulted during the current +/// full-collection epoch (`shapes_birth_width`). Keeps the record, and so the +/// width it learned, through the next synchronous full prune; the epoch +/// rotation clears it. +pub(super) const RECORD_FLAG_BIRTH_OWNER: u8 = 1 << 5; pub(super) const RECORD_FLAG_CARRIED_SEEN: u8 = 1 << 6; pub(super) const RECORD_FLAG_EXTERNAL_CARRIER: u8 = 1 << 7; @@ -64,9 +69,11 @@ pub(crate) struct ShapeRecord { pub(super) live_inline_slot_count: u32, pub(super) hole_count: u32, /// Low 8 bits: the `RECORD_FLAG_*` set. Bits 8-9: the `ShapeObjectKind` - /// discriminant. Bits 16-23: the attribute SUMMARY byte - /// (`key_attrs::SUMMARY_*`), an identity fact. Bits 10-15 and 24-31: - /// reserved. + /// discriminant. Bits 10-15: the births a keyless birth shape served while + /// tracking its width (#10905). Bits 16-23: the attribute SUMMARY byte + /// (`key_attrs::SUMMARY_*`), an identity fact. Bits 24-31: the inline + /// width a keyless birth shape's descendants grow to (#10905). The two + /// #10905 fields are learned facts of the record, never identity. /// /// This word replaces the old `flags: u8` plus `_pad: [u8; 3]`. It is the /// same four bytes in the same place, so the record stays 32 bytes and @@ -87,6 +94,13 @@ const RECORD_KIND_MASK: u32 = 0b11 << RECORD_KIND_SHIFT; const RECORD_SUMMARY_SHIFT: u32 = 16; const RECORD_SUMMARY_MASK: u32 = 0xFF << RECORD_SUMMARY_SHIFT; +/// #10905 (`shapes_birth_width`): births served while tracking, bits 10-15. +const RECORD_BIRTHS_SHIFT: u32 = 10; +const RECORD_BIRTHS_MASK: u32 = 0x3F << RECORD_BIRTHS_SHIFT; +/// #10905 (`shapes_birth_width`): the learned descendant width, bits 24-31. +const RECORD_WIDTH_SHIFT: u32 = 24; +const RECORD_WIDTH_MASK: u32 = 0xFF << RECORD_WIDTH_SHIFT; + const _: () = assert!(std::mem::size_of::() == 40); const _: () = assert!(std::mem::align_of::() == 8); @@ -147,6 +161,37 @@ impl ShapeRecord { self } + /// The inline width this keyless birth shape's descendants grow to + /// (#10905), or 0 when nothing was learned. + #[inline] + pub(super) fn descendant_width(&self) -> u32 { + (self.flags_and_kind & RECORD_WIDTH_MASK) >> RECORD_WIDTH_SHIFT + } + + /// Raise [`ShapeRecord::descendant_width`] to `width` (monotone, + /// saturating at the byte). + #[inline] + pub(super) fn note_descendant_width(&mut self, width: u32) { + let width = width.min(RECORD_WIDTH_MASK >> RECORD_WIDTH_SHIFT); + if width > self.descendant_width() { + self.flags_and_kind = + (self.flags_and_kind & !RECORD_WIDTH_MASK) | (width << RECORD_WIDTH_SHIFT); + } + } + + /// Births this keyless birth shape served while tracking (#10905). + #[inline] + pub(super) fn tracked_births(&self) -> u32 { + (self.flags_and_kind & RECORD_BIRTHS_MASK) >> RECORD_BIRTHS_SHIFT + } + + #[inline] + pub(super) fn set_tracked_births(&mut self, births: u32) { + let births = births.min(RECORD_BIRTHS_MASK >> RECORD_BIRTHS_SHIFT); + self.flags_and_kind = + (self.flags_and_kind & !RECORD_BIRTHS_MASK) | (births << RECORD_BIRTHS_SHIFT); + } + #[inline] pub(super) fn object_kind(&self) -> ShapeObjectKind { match (self.flags_and_kind & RECORD_KIND_MASK) >> RECORD_KIND_SHIFT { diff --git a/crates/perry-runtime/src/object/spill.rs b/crates/perry-runtime/src/object/spill.rs index 09f2843264..5dad8cc6e5 100644 --- a/crates/perry-runtime/src/object/spill.rs +++ b/crates/perry-runtime/src/object/spill.rs @@ -424,6 +424,12 @@ fn hot_learned_inline_fields() -> &'static LearnedInlineTable { #[inline] fn note_learned_inline_fields(obj_ptr: usize, class_id: u32, needed_fields: u32) { + // #10905: a spill also teaches the object's keyless birth shape, if it has + // one, how wide its descendants grow (`shapes_birth_width`). + // SAFETY: every caller passes a live shaped object it is storing into. + unsafe { + crate::object::shapes::note_spill_width(obj_ptr as *const ObjectHeader, needed_fields) + }; if class_id == 0 || needed_fields > LEARNED_INLINE_MAX_FIELDS { return; } From a8b0b06c2daf5ebcf5bf0a324325711f41f41b8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 20:10:43 +0200 Subject: [PATCH 3/4] changelog: drop the pre-rename #11540 fragment the merge resurrected --- changelog.d/object-create-ordinary-birth.md | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 changelog.d/object-create-ordinary-birth.md diff --git a/changelog.d/object-create-ordinary-birth.md b/changelog.d/object-create-ordinary-birth.md deleted file mode 100644 index ed6f2802b0..0000000000 --- a/changelog.d/object-create-ordinary-birth.md +++ /dev/null @@ -1,9 +0,0 @@ -Fixed a ~1,300 instructions-per-store regression on `Object.create(proto)` -receivers. Since `Object.create` stopped minting a synthetic class id per call -(#11166), its result is class-less, and nothing marked it an ordinary object, so -the static-key store site's receiver-kind test refused it: every `o.k = v` -missed the site cache and took the full `[[Set]]` walk, even for an own data -property. `Object.create` now births its result ordinary, like the other -ordinary birth sites, and the store site publishes its shape as it does for a -literal or a class instance. On the acceptance matrix the `Object.create` -overwrite cell goes from 1,588 back to about 300 instructions per store. From 452845bfdb4d6a36b3a985fb700c9a4b44f3fc86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 20:11:19 +0200 Subject: [PATCH 4/4] changelog: name the fragment after PR #11566 --- .../{ocreate-birth-width.md => 11566-ocreate-birth-width.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{ocreate-birth-width.md => 11566-ocreate-birth-width.md} (100%) diff --git a/changelog.d/ocreate-birth-width.md b/changelog.d/11566-ocreate-birth-width.md similarity index 100% rename from changelog.d/ocreate-birth-width.md rename to changelog.d/11566-ocreate-birth-width.md