diff --git a/changelog.d/11566-ocreate-birth-width.md b/changelog.d/11566-ocreate-birth-width.md new file mode 100644 index 0000000000..aef7503ad6 --- /dev/null +++ b/changelog.d/11566-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 b519121412..b3ca31fd67 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); } }); } @@ -2930,7 +2945,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; }