diff --git a/changelog.d/8019-exotic-expando-runtime-state.md b/changelog.d/8019-exotic-expando-runtime-state.md new file mode 100644 index 0000000000..42eb41bb59 --- /dev/null +++ b/changelog.d/8019-exotic-expando-runtime-state.md @@ -0,0 +1,8 @@ +### Complete the #6759 exotic-expando RuntimeState migration + +Date, RegExp, Promise, Map, Set, and Temporal expando values now share the +owning thread's explicit `RuntimeState` with the other hot object-model tables, +instead of resolving a separate table TLS key plus an independent in-use gate. +GC root scanning, owner rekeying, dead-owner pruning, and worker isolation keep +their existing semantics; the shape-tree implementation record now also names +which stronger Phase B/C RFC goals remain separate work. diff --git a/crates/perry-runtime/src/object/exotic_expando.rs b/crates/perry-runtime/src/object/exotic_expando.rs index c6cadaac34..73076681c1 100644 --- a/crates/perry-runtime/src/object/exotic_expando.rs +++ b/crates/perry-runtime/src/object/exotic_expando.rs @@ -101,56 +101,68 @@ pub(crate) fn exotic_expando_kind_of_value(value: f64) -> Option<(usize, ExoticK exotic_expando_kind(addr).map(|kind| (addr, kind)) } -crate::perry_thread_local! { - /// addr -> insertion-ordered (key, nanboxed value bits) pairs (Date/RegExp). - static EXOTIC_EXPANDO: RefCell>> = - RefCell::new(HashMap::new()); +/// #6759 Phase A: exotic-cell expando storage grouped under the owning +/// thread's [`crate::state::RuntimeState`]. Keeping the gate beside the map +/// lets each operation fetch the runtime state once and reuse it, rather than +/// resolving two independent TLS keys on every first store or guarded lookup. +pub(crate) struct ExoticExpandoTables { + /// addr -> insertion-ordered (key, nanboxed value bits) pairs for the + /// non-Error exotic cells handled by this module. + entries: RefCell>>, /// Fast-path gate so hot get/set paths skip the map lookup until the /// first expando is installed on this thread. - static EXPANDO_IN_USE: Cell = const { Cell::new(false) }; + in_use: Cell, +} + +impl ExoticExpandoTables { + pub(crate) fn new() -> Self { + Self { + entries: RefCell::new(HashMap::new()), + in_use: Cell::new(false), + } + } } pub(crate) fn expando_in_use() -> bool { - EXPANDO_IN_USE.with(|c| c.get()) + crate::state::state().exotic_expando.in_use.get() } fn expando_store(addr: usize, key: &str, bits: u64) { - EXPANDO_IN_USE.with(|c| c.set(true)); - EXOTIC_EXPANDO.with(|m| { - let mut map = m.borrow_mut(); - let entries = map.entry(addr).or_default(); - if let Some(slot) = entries.iter_mut().find(|(k, _)| k == key) { - slot.1 = bits; - } else { - entries.push((key.to_string(), bits)); - } - }); + let tables = &crate::state::state().exotic_expando; + tables.in_use.set(true); + let mut map = tables.entries.borrow_mut(); + let entries = map.entry(addr).or_default(); + if let Some(slot) = entries.iter_mut().find(|(k, _)| k == key) { + slot.1 = bits; + } else { + entries.push((key.to_string(), bits)); + } } fn expando_lookup(addr: usize, key: &str) -> Option { - if !expando_in_use() { + let tables = &crate::state::state().exotic_expando; + if !tables.in_use.get() { return None; } - EXOTIC_EXPANDO.with(|m| { - m.borrow() - .get(&addr) - .and_then(|entries| entries.iter().find(|(k, _)| k == key).map(|(_, v)| *v)) - }) + tables + .entries + .borrow() + .get(&addr) + .and_then(|entries| entries.iter().find(|(k, _)| k == key).map(|(_, v)| *v)) } fn expando_remove(addr: usize, key: &str) -> bool { - if !expando_in_use() { + let tables = &crate::state::state().exotic_expando; + if !tables.in_use.get() { return false; } - EXOTIC_EXPANDO.with(|m| { - let mut map = m.borrow_mut(); - if let Some(entries) = map.get_mut(&addr) { - let before = entries.len(); - entries.retain(|(k, _)| k != key); - return entries.len() != before; - } - false - }) + let mut map = tables.entries.borrow_mut(); + if let Some(entries) = map.get_mut(&addr) { + let before = entries.len(); + entries.retain(|(k, _)| k != key); + return entries.len() != before; + } + false } /// Kind-dispatched own data-property store: Error delegates to the @@ -187,15 +199,16 @@ fn value_keys(kind: ExoticKind, addr: usize) -> Vec { .map(|(k, _)| k) .collect(), _ => { - if !expando_in_use() { + let tables = &crate::state::state().exotic_expando; + if !tables.in_use.get() { return Vec::new(); } - EXOTIC_EXPANDO.with(|m| { - m.borrow() - .get(&addr) - .map(|entries| entries.iter().map(|(k, _)| k.clone()).collect()) - .unwrap_or_default() - }) + tables + .entries + .borrow() + .get(&addr) + .map(|entries| entries.iter().map(|(k, _)| k.clone()).collect()) + .unwrap_or_default() } } } @@ -204,12 +217,11 @@ fn value_keys(kind: ExoticKind, addr: usize) -> Vec { /// cell. Called from Date / RegExp allocation so address reuse can't leak /// the old instance's properties onto the new one. pub(crate) fn expando_clear_on_alloc(addr: usize) { - if !expando_in_use() { + let tables = &crate::state::state().exotic_expando; + if !tables.in_use.get() { return; } - EXOTIC_EXPANDO.with(|m| { - m.borrow_mut().remove(&addr); - }); + tables.entries.borrow_mut().remove(&addr); } /// Death pruning (2026-07-09 GC audit wave 2): the root scanner @@ -222,31 +234,35 @@ pub(crate) fn expando_clear_on_alloc(addr: usize) { /// Temporal cells that die PINNED are skipped by the predicate's pinned /// check and remain covered by the clear-on-alloc path. pub(crate) fn prune_dead_exotic_expando_owners(is_dead_owner: &dyn Fn(usize) -> bool) { - if !expando_in_use() { + let tables = &crate::state::state().exotic_expando; + if !tables.in_use.get() { return; } - EXOTIC_EXPANDO.with(|m| { - let mut map = m.borrow_mut(); - if !map.is_empty() { - map.retain(|owner, _| !is_dead_owner(*owner)); - } - }); + let mut map = tables.entries.borrow_mut(); + if !map.is_empty() { + map.retain(|owner, _| !is_dead_owner(*owner)); + } } #[cfg(test)] pub(crate) fn test_seed_exotic_expando_entry(addr: usize, key: &str, value_bits: u64) { - EXPANDO_IN_USE.with(|c| c.set(true)); - EXOTIC_EXPANDO.with(|m| { - m.borrow_mut() - .entry(addr) - .or_default() - .push((key.to_string(), value_bits)); - }); + let tables = &crate::state::state().exotic_expando; + tables.in_use.set(true); + tables + .entries + .borrow_mut() + .entry(addr) + .or_default() + .push((key.to_string(), value_bits)); } #[cfg(test)] pub(crate) fn test_exotic_expando_entry_exists(addr: usize) -> bool { - EXOTIC_EXPANDO.with(|m| m.borrow().contains_key(&addr)) + crate::state::state() + .exotic_expando + .entries + .borrow() + .contains_key(&addr) } /// Rekey a movable exotic cell's expando entry after the GC relocates it from @@ -257,15 +273,14 @@ pub(crate) fn test_exotic_expando_entry_exists(addr: usize) -> bool { /// are already rewritten by `scan_exotic_expando_roots_mut`; this migrates the /// owner *key*. Wired via `GcMoveHookKind::ExoticExpandoOwner`. pub(crate) fn exotic_expando_owner_moved(old_addr: usize, new_addr: usize) { - if !expando_in_use() || old_addr == new_addr { + let tables = &crate::state::state().exotic_expando; + if !tables.in_use.get() || old_addr == new_addr { return; } - EXOTIC_EXPANDO.with(|m| { - let mut map = m.borrow_mut(); - if let Some(entries) = map.remove(&old_addr) { - map.insert(new_addr, entries); - } - }); + let mut map = tables.entries.borrow_mut(); + if let Some(entries) = map.remove(&old_addr) { + map.insert(new_addr, entries); + } } /// `[[Set]]` on a Date/RegExp/Error instance. Honors accessor descriptors @@ -613,12 +628,47 @@ pub(crate) fn exotic_put_value_set( /// GC mutable-root scanner: keeps expando values alive (and rewrites them if /// the collector relocates the referenced heap objects). pub fn scan_exotic_expando_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { - EXOTIC_EXPANDO.with(|m| { - let mut map = m.borrow_mut(); - for (_, entries) in map.iter_mut() { - for (_, bits) in entries.iter_mut() { - visitor.visit_nanbox_u64_slot(bits); - } + let mut map = crate::state::state().exotic_expando.entries.borrow_mut(); + for (_, entries) in map.iter_mut() { + for (_, bits) in entries.iter_mut() { + visitor.visit_nanbox_u64_slot(bits); } - }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_state_keeps_exotic_expandos_thread_local() { + let marker = 0u8; + let owner = &marker as *const u8 as usize; + let key = "runtime-state-isolation"; + + test_seed_exotic_expando_entry(owner, key, crate::value::TAG_UNDEFINED); + assert!(test_exotic_expando_entry_exists(owner)); + + std::thread::spawn(move || { + assert!( + !test_exotic_expando_entry_exists(owner), + "a worker observed the parent thread's expando table" + ); + test_seed_exotic_expando_entry(owner, key, crate::value::TAG_TRUE); + assert_eq!(expando_lookup(owner, key), Some(crate::value::TAG_TRUE)); + }) + .join() + .expect("worker test thread panicked"); + + assert!( + test_exotic_expando_entry_exists(owner), + "the worker's RuntimeState disturbed the parent table" + ); + assert_eq!( + expando_lookup(owner, key), + Some(crate::value::TAG_UNDEFINED), + "the worker overwrote the parent thread's expando value" + ); + assert!(expando_remove(owner, key)); + } } diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 45184af4b5..6af3a978cb 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -1551,9 +1551,14 @@ pub struct ObjectHeader { /// garbage; every `meta` access must first establish a genuine shaped /// object (`object_meta_slot_addr` centralizes that check). /// -/// Phase B lands incrementally: today the record holds the custom -/// `[[Prototype]]` plus the Phase C2 per-key descriptor summaries; the -/// exotic-kind tag migrates here next (#6759). +/// The shipped Phase B record holds the custom `[[Prototype]]`, the Phase C2 +/// per-key descriptor summaries, object flags, and owned spill storage. The +/// RFC also sketched an exotic-kind tag here, but Date/RegExp/Error/Promise/ +/// Map/Set/Temporal have distinct cell layouts rather than an `ObjectHeader`; +/// representing their kind here first requires header unification. Their +/// expando payloads therefore remain in the per-thread `RuntimeState` with GC +/// rekey/prune defenses instead of being described as the next incremental +/// `ObjectMeta` migration. #[repr(C)] pub struct ObjectMeta { /// Custom `[[Prototype]]` recorded by `Object.setPrototypeOf` / object diff --git a/crates/perry-runtime/src/state.rs b/crates/perry-runtime/src/state.rs index 332d4ea0e9..f764b1c1f3 100644 --- a/crates/perry-runtime/src/state.rs +++ b/crates/perry-runtime/src/state.rs @@ -40,6 +40,10 @@ pub(crate) struct RuntimeState { /// Property-lookup inline caches: the direct-mapped field cache /// (previously a `thread_local!` in `object::field_get_set`). pub(crate) field_lookup: crate::object::FieldLookupCaches, + /// User-defined properties attached to non-`ObjectHeader` cells such as + /// Date, RegExp, Promise, Map, and Set (previously two `thread_local!`s in + /// `object::exotic_expando`). + pub(crate) exotic_expando: crate::object::exotic_expando::ExoticExpandoTables, /// #6759 Phase C1: first-class Shape records keyed on keys_array /// identity (see `object::shapes` and docs/shape-tree-plan.md). pub(crate) shapes: crate::object::ShapeTable, @@ -51,6 +55,7 @@ impl RuntimeState { descriptors: crate::object::DescriptorTables::new(), object_hot: crate::object::ObjectHotTables::new(), field_lookup: crate::object::FieldLookupCaches::new(), + exotic_expando: crate::object::exotic_expando::ExoticExpandoTables::new(), shapes: crate::object::ShapeTable::new(), }) } diff --git a/docs/shape-tree-plan.md b/docs/shape-tree-plan.md index 02c3ace477..a85628365b 100644 --- a/docs/shape-tree-plan.md +++ b/docs/shape-tree-plan.md @@ -1,9 +1,34 @@ -# Shape tree (#6759 Phase C) — design - -Status: DRAFT for design review (the Phase C entry gate #6759 calls for). -Prerequisites: Phase A (`RuntimeState`, per-thread hot tables) and Phase B -(`ObjectHeader.meta` + `GC_TYPE_OBJECT_META`) — both stacked under this -plan's first landing. +# Shape tree (#6759 Phase C) — design and implementation record + +Status: IMPLEMENTED IN STAGES; audited against `main` on 2026-08-13. + +Phase A (`RuntimeState`, per-thread hot tables) and Phase B +(`ObjectHeader.meta` + `GC_TYPE_OBJECT_META`) landed before the first Phase C +change. This document began as the Phase C review gate; the audit below records +where the shipped implementation deliberately differs from that original end +state. + +## Implementation audit + +| area | landed | deliberately still separate | +|---|---|---| +| Phase A: explicit runtime state | #6795 grouped the descriptor, object-storage, field-lookup, shape, and transition hot tables. The exotic-expando table is now in the same `RuntimeState`. Later work made remaining runtime TLS use the `perry_thread_local!` hot cache and added the Darwin TLS budget gate (#7758). | Receiver-specific registries that are not on the ordinary-object hot path remain in their owning modules. Phase A did not turn every runtime TLS value into a `RuntimeState` field. | +| Phase B: self-describing headers | #6796 added the traced `ObjectHeader.meta` edge and migrated custom prototypes. #6800 added per-owner descriptor summary words. Object-owned overflow storage later moved into `ObjectMeta.spill`. | Property/accessor descriptor payloads are still authoritative in address-keyed tables. Date, RegExp, Error, Promise, Map, Set, and Temporal use distinct GC cell layouts, so their kind and expando payloads cannot be represented by `ObjectHeader.meta` without first unifying those headers. Their tables retain GC rekey/prune/clear-on-allocation defenses. | +| Phase C: first-class shapes | #6797 added shared key→slot `Shape` records; #6801/#6803 added stable, never-reused ShapeIds and GC rekeying; #6807/#6808 made allocation and read PICs compare discriminated shape tokens. #7981/#7983/#8009/#8010 then made the header shape word uniform across plain objects and class instances and birth-stamped every known allocator. | `keys_array` remains the ordered key artifact. The transition cache and `FIELD_CACHE` still exist as shape-keyed accelerators, and churn-heavy objects do not switch to an authoritative dictionary representation. `Object.keys` still creates a fresh result by walking the keys array, as required by the JS API. | + +The architectural construction is therefore in production: a per-thread +runtime state, a traced per-object metadata edge, stable shape identity, and +exact shape-token PIC guards. The stronger literal reading of the original RFC — no +address-keyed descriptor payloads, uniform exotic headers, shape-resident +transition edges, and formal dictionary mode — is not implemented and should +not be inferred from the merged phase labels. + +The original “within 1.5× Node” table is historical. Maintainer direction on +Issue `#6759` raised the performance bar to beat Node and split the remaining compiler +coverage into follow-up campaigns. #6811 beat Node on the canonical object-write +micro; #6812 tracks generalizing that narrow win. Static inline `in` caches and +shape-cached enumeration remain separately scopeable work rather than hidden +requirements of the already-landed shape identity. ## Goal @@ -163,13 +188,13 @@ Each step lands independently behind green suites, per the #6759 method. (process-rooted, address-immortal) keys arrays, closing a latent ABA hazard where an owned array's recycled address could satisfy the unvalidated inline compare. - - **C3-codegen (remaining, own review gate)**: eager id stamping at - allocation (so typed_feedback observation tokens can canonicalize on - ids without the lazy-stamp two-token split) and the PIC comparing - the header id — needs a discriminated compare because the generic - PIC also serves class instances, whose `parent_class_id` is real - inheritance data. Folding the transition cache into shape-resident - edges rides the same rung. + - **C3-codegen (landed, then generalized)**: #6807/#6808 added eager id + stamping and discriminated ShapeId PIC tokens. #7981 moved the serialized + inheritance edge to the class registry; #7983 made the header word a + uniform shape word for plain objects and class instances; #8009/#8010 + birth-stamped the compiled and runtime allocator families so one shape's + population cannot split between pointer and id tokens. Folding the + transition cache into shape-resident edges remains deferred. - **C4 — dictionary mode: largely subsumed.** The concrete goals — per-shape hash lookup for wide objects, churn not corrupting acceleration, eager invalidation on delete/compaction, @@ -184,12 +209,13 @@ Each step lands independently behind green suites, per the #6759 method. per-key against declared instance-field names (with a late-class retro-check), so babel-style prototype method installs stop poisoning `this.field` access process-wide. - - Remaining: guard families vetting one exact shape id — gated on - eager stamping (see C3-codegen above). + - Remaining guard families may now vet one exact shape id; eager stamping is + no longer their blocker. -C1, C2, C3a, C3c-r, and C5a are runtime-only. The C3 codegen remainder -gets its own review before landing. Acceptance is measured against the -#6759 micro table. +C1, C2, C3a, C3c-r, and C5a were runtime-only. The C3 codegen work and later +uniform-shape-word follow-ups landed under their own reviews. New performance +claims should use the current follow-up issue's measurement protocol rather +than the original #6759 absolute timings. ## GC story @@ -206,10 +232,11 @@ gets its own review before landing. Acceptance is measured against the ## Risks / open questions for review -1. **Class instances** (`class_id != 0`, `keys_array == null`) resolve - fields via class layout, not keys_array — C1 deliberately does not - touch them; C3's unification must fold class layouts and anon shapes - into one shape-id space without disturbing vtable dispatch. +1. **Class instances — resolved for identity.** C1 deliberately did not touch + them. #7981/#7983/#8009/#8010 made their header shape word and birth-stamp + discipline match plain objects without disturbing class-registry vtable or + inheritance dispatch. Class layouts still remain their field-definition + source; “uniform” means the cache identity contract, not identical storage. 2. **Delete/compaction** rewrites keys_arrays in place for owned arrays — C1 handles it via `indexed_len` shrink detection (same as WIDE_KEY_INDEX today); C4 is the real answer. diff --git a/scripts/gc_runtime_root_holders.py b/scripts/gc_runtime_root_holders.py index cd514ad04d..d2c809b863 100755 --- a/scripts/gc_runtime_root_holders.py +++ b/scripts/gc_runtime_root_holders.py @@ -68,11 +68,12 @@ * **`RuntimeState`-owned tables.** `crates/perry-runtime/src/state.rs` absorbed roughly a dozen former `thread_local!`s (`descriptors`, `object_hot` and its `overflow_fields` / `shape_cache_overflow` / `transition_cache`, - `field_lookup`, `shapes`). They are struct FIELDS, reached through `state()`, - so no declaration-site scan sees them. All are covered today; a new field - added there is invisible here. `STATE_FIELD_FLOOR` below asserts the struct - has not grown past the field count this was checked at, so growth is at least - *loud*. + `field_lookup`, `shapes`, and `exotic_expando`). They are struct FIELDS, + reached through `state()`, so no declaration-site scan sees them. All are + covered today: in particular, `exotic_expando` is visited by + `scan_exotic_expando_roots_mut`. A new field added there is invisible here. + `STATE_FIELD_FLOOR` below asserts the struct has not grown past the field + count this was checked at, so growth is at least *loud*. * **An integer-typed holder whose own file never calls an allocator.** Rule B needs a function that both names the holder and allocates; a cell written purely from a value handed in across a module boundary has neither, and is @@ -186,7 +187,7 @@ def repo_relative(path: PurePath, root: PurePath) -> str: # and are invisible to DECL; this makes the struct growing at least loud. STATE_FILE = "crates/perry-runtime/src/state.rs" STATE_STRUCT = "struct RuntimeState" -STATE_FIELD_FLOOR = 4 +STATE_FIELD_FLOOR = 5 def source_files(root: Path) -> list[Path]: