From 10690580d05378df651c3adf82dffabc728ed19a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 20:09:17 +0200 Subject: [PATCH 1/2] runtime: the Array-subclass elements store becomes the default representation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `class X extends Array` instances keep their indexed elements and `length` in `ObjectMeta.elements` (#8966) instead of shape-carried properties, so `push`/`pop`/`obj[i]` are element operations rather than property-shape transitions. `PERRY_ARRAY_SUBCLASS_ELEMENTS=0` restores the previous representation as a bisecting kill switch. Evidence for the flip: * the whole `test-files/` corpus compiled once and run twice (the gate is a pure runtime switch): 1285 binaries, 9 output differences, every one of them nondeterministic output — random bytes, timestamps, `console.time` values, a PID in a deprecation warning, a flaky watcher fixture that fails with the store OFF — each reproducible with the switch untouched; * the Array-subclass integration suites pass with the store enabled (indexing, super-init, native member base, loop-versioned array-like, closure-capture packed loops, object array-like dispatch, interface dispatch, field-push write-back); * wolf-ecs twins on the quiet Mac, 11 alternating pairs, same binary: add/remove −11.4%, entity cycle −11.9%, 11/11 in both the 2 s and 50 ms windows. Semantics move toward node: `JSON.stringify` produces the array form, `Object.keys` no longer leaks `length`, and `sort`/`reverse`/`splice`/ `shift`/`unshift`, `length` truncation, holes and spread become node-identical (they printed the object form `{"0":…,"length":…}` before). The shape-carried form stays reachable through the kill switch, so its unit tests now pin it explicitly with `ArraySubclassRepresentationGuard`; the elements tests pin the other direction. Claude-Session: https://claude.ai/code/session_019WVcWKmYsUBnnFB7nBgbBJ --- .../0000-array-subclass-elements-default.md | 3 + .../src/array/subclass_elements.rs | 63 +++++++++++++++++-- .../src/array/subclass_elements_tests.rs | 7 ++- .../perry-runtime/src/array/subclass_tests.rs | 52 +++++++++++++++ 4 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 changelog.d/0000-array-subclass-elements-default.md diff --git a/changelog.d/0000-array-subclass-elements-default.md b/changelog.d/0000-array-subclass-elements-default.md new file mode 100644 index 0000000000..9930cba91e --- /dev/null +++ b/changelog.d/0000-array-subclass-elements-default.md @@ -0,0 +1,3 @@ +### Changed + +- `class X extends Array` instances now keep their indexed elements and `length` in a real elements store (`ObjectMeta.elements`) instead of shape-carried properties, so `push`/`pop`/`obj[i]` are element operations rather than property-shape transitions: −11.4% (add/remove) and −11.9% (entity cycle) on the wolf-ecs benchmarks. Semantics move toward node — `JSON.stringify` produces the array form, `Object.keys` no longer leaks `length`, and the mutator surface matches node exactly. `PERRY_ARRAY_SUBCLASS_ELEMENTS=0` restores the previous representation for bisecting. diff --git a/crates/perry-runtime/src/array/subclass_elements.rs b/crates/perry-runtime/src/array/subclass_elements.rs index 2b68960cd8..cc5c34f3e1 100644 --- a/crates/perry-runtime/src/array/subclass_elements.rs +++ b/crates/perry-runtime/src/array/subclass_elements.rs @@ -15,19 +15,74 @@ use crate::object::ObjectHeader; use super::subclass::{mutation_receiver_allows_plain_tail, ValidatedObjectReceiver}; -/// `PERRY_ARRAY_SUBCLASS_ELEMENTS=1|on|true` — off while the property entry -/// points are being routed; the default flips once the semantics suite is green. +/// The elements store is the DEFAULT representation for `class X extends +/// Array` instances; `PERRY_ARRAY_SUBCLASS_ELEMENTS=0` restores the +/// shape-carried form (a bisecting kill switch, not a supported mode). +/// +/// Flipped on after: the whole `test-files/` corpus compiled once and run +/// twice under both settings (1285 binaries, 9 output differences, every one +/// of them nondeterministic output — random bytes, timestamps, +/// `console.time`, a PID, a flaky watcher — each reproducible with the switch +/// untouched); the Array-subclass integration suites green with the store +/// enabled; and, on the wolf-ecs twins, −11.4% (add/remove) and −11.9% +/// (entity cycle), 11/11 pairs in both the 2 s and 50 ms windows. Semantics +/// move TOWARD node: `JSON.stringify` produces the array form, `Object.keys` +/// no longer leaks `length`, and the mutator surface +/// (`sort`/`reverse`/`splice`/`shift`/`unshift`, `length` truncation, holes, +/// spread) becomes node-identical. #[inline] pub(crate) fn array_subclass_elements_enabled() -> bool { + #[cfg(test)] + if let Some(forced) = FORCED_REPRESENTATION.with(std::cell::Cell::get) { + return forced; + } static ON: std::sync::OnceLock = std::sync::OnceLock::new(); *ON.get_or_init(|| { - matches!( + !matches!( std::env::var("PERRY_ARRAY_SUBCLASS_ELEMENTS").as_deref(), - Ok("1") | Ok("on") | Ok("true") + Ok("0") | Ok("off") | Ok("false") ) }) } +#[cfg(test)] +thread_local! { + static FORCED_REPRESENTATION: std::cell::Cell> = + const { std::cell::Cell::new(None) }; +} + +/// Pins the representation for one test, whatever the process default is. +/// +/// The shape-carried form stays reachable through the kill switch, so its +/// tests (`super::subclass_tests`) name it explicitly rather than rely on the +/// default; the elements tests do the same in the other direction. +#[cfg(test)] +pub(crate) struct ArraySubclassRepresentationGuard(Option); + +#[cfg(test)] +impl ArraySubclassRepresentationGuard { + /// Indexed elements and `length` are shape-carried properties. + pub(crate) fn shape_carried() -> Self { + Self::force(false) + } + + /// Indexed elements and `length` live in `ObjectMeta.elements`. + pub(crate) fn elements() -> Self { + Self::force(true) + } + + fn force(value: bool) -> Self { + Self(FORCED_REPRESENTATION.with(|cell| cell.replace(Some(value)))) + } +} + +#[cfg(test)] +impl Drop for ArraySubclassRepresentationGuard { + fn drop(&mut self) { + FORCED_REPRESENTATION.with(|cell| cell.set(self.0)); + } +} + /// The elements store of a live `GC_TYPE_OBJECT`, or null when it has none /// (no meta record, or not an elements-backed Array subclass instance). /// diff --git a/crates/perry-runtime/src/array/subclass_elements_tests.rs b/crates/perry-runtime/src/array/subclass_elements_tests.rs index 954586dba6..2ffc2ba0f4 100644 --- a/crates/perry-runtime/src/array/subclass_elements_tests.rs +++ b/crates/perry-runtime/src/array/subclass_elements_tests.rs @@ -1,7 +1,9 @@ //! The `ObjectMeta.elements` edge of an Array-subclass instance is a traced //! child exactly like `spill`: it must survive owner and meta evacuation, be //! rewritten to the moved inner array, and keep the inner array alive. -use super::subclass_elements::{elements_of, install_elements, set_elements_head}; +use super::subclass_elements::{ + elements_of, install_elements, set_elements_head, ArraySubclassRepresentationGuard, +}; use crate::object::{js_object_alloc, ObjectHeader}; const CLASS_ID_ARRAY: u32 = 0xFFFF_0024; @@ -173,6 +175,7 @@ fn truthy(v: f64) -> bool { /// property descriptors — and no index key ever lands in the shape. #[test] fn the_property_funnel_answers_indices_and_length_from_the_store() { + let _representation = ArraySubclassRepresentationGuard::elements(); let _triggers = crate::gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers(); crate::gc::register_runtime_handle_root_scanner_for_tests(); let class_id = 0x0074_8697; @@ -297,6 +300,7 @@ fn the_property_funnel_answers_indices_and_length_from_the_store() { /// detached, and the frozen instance reads back exactly the same. #[test] fn freeze_deopts_to_the_shape_carried_form() { + let _representation = ArraySubclassRepresentationGuard::elements(); let _triggers = crate::gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers(); crate::gc::register_runtime_handle_root_scanner_for_tests(); let class_id = 0x0074_8698; @@ -345,6 +349,7 @@ fn freeze_deopts_to_the_shape_carried_form() { /// property path. #[test] fn the_counted_loop_guard_admits_an_elements_backed_receiver_as_its_inner_array() { + let _representation = ArraySubclassRepresentationGuard::elements(); let _triggers = crate::gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers(); crate::gc::register_runtime_handle_root_scanner_for_tests(); let class_id = 0x0074_8699; diff --git a/crates/perry-runtime/src/array/subclass_tests.rs b/crates/perry-runtime/src/array/subclass_tests.rs index 487214ed7f..00e50ede25 100644 --- a/crates/perry-runtime/src/array/subclass_tests.rs +++ b/crates/perry-runtime/src/array/subclass_tests.rs @@ -178,6 +178,10 @@ fn array_object_receiver_is_safe_for_non_pointers_and_handle_band_ids() { /// its side exit after a structural mutation. #[test] fn dense_array_subclass_reads_slots_until_its_shape_changes() { + // Pins the shape-carried representation: the elements store is the + // default, and this test is about the property-shape machinery. + let _representation = + super::subclass_elements::ArraySubclassRepresentationGuard::shape_carried(); let class_id = 0x0074_8655; crate::object::js_register_class_parent(class_id, CLASS_ID_ARRAY); let obj = js_object_alloc(class_id, 2); @@ -248,6 +252,10 @@ fn dense_array_subclass_reads_slots_until_its_shape_changes() { #[test] fn dense_array_subclass_cache_declines_a_per_instance_prototype_override() { + // Pins the shape-carried representation: the elements store is the + // default, and this test is about the property-shape machinery. + let _representation = + super::subclass_elements::ArraySubclassRepresentationGuard::shape_carried(); let class_id = 0x0074_865A; crate::object::js_register_class_parent(class_id, CLASS_ID_ARRAY); let obj = js_object_alloc(class_id, 2); @@ -274,6 +282,10 @@ fn dense_array_subclass_cache_declines_a_per_instance_prototype_override() { /// ShapeId, so exact identity makes this test non-vacuous. #[test] fn dense_array_subclass_tail_transitions_reuse_exact_shapes_and_slots() { + // Pins the shape-carried representation: the elements store is the + // default, and this test is about the property-shape machinery. + let _representation = + super::subclass_elements::ArraySubclassRepresentationGuard::shape_carried(); let _global = crate::gc::global_side_table_test_lock(); crate::object::array_tail_transition::test_clear(); let class_id = 0x0074_8657; @@ -342,6 +354,10 @@ fn dense_array_subclass_tail_transitions_reuse_exact_shapes_and_slots() { #[test] fn array_subclass_length_ic_publishes_only_scalar_exact_or_family_facts() { + // Pins the shape-carried representation: the elements store is the + // default, and this test is about the property-shape machinery. + let _representation = + super::subclass_elements::ArraySubclassRepresentationGuard::shape_carried(); let _global = crate::gc::global_side_table_test_lock(); crate::object::array_tail_transition::test_clear(); let class_id = 0x0074_867b; @@ -398,6 +414,10 @@ fn array_subclass_length_ic_publishes_only_scalar_exact_or_family_facts() { /// the ordinary barriered slot-store path. #[test] fn dense_array_subclass_numeric_tail_store_preserves_tagged_fallbacks() { + // Pins the shape-carried representation: the elements store is the + // default, and this test is about the property-shape machinery. + let _representation = + super::subclass_elements::ArraySubclassRepresentationGuard::shape_carried(); let _global = crate::gc::global_side_table_test_lock(); crate::object::array_tail_transition::test_clear(); let class_id = 0x0074_865c; @@ -510,6 +530,10 @@ fn fused_u31_push_reports_length_for_plain_and_subclass_arrays() { /// mark once no live entry names them (eviction / tombstone / test clear). #[test] fn transition_cache_carrier_bits_follow_live_occupancy_across_full_trace_recompute() { + // Pins the shape-carried representation: the elements store is the + // default, and this test is about the property-shape machinery. + let _representation = + super::subclass_elements::ArraySubclassRepresentationGuard::shape_carried(); let _global = crate::gc::global_side_table_test_lock(); crate::object::array_tail_transition::test_clear(); let class_id = 0x0074_8695; @@ -629,6 +653,10 @@ fn spec_and_generic_push_entries_append_to_an_object_backed_subclass_densely() { #[test] fn array_subclass_named_prefix_token_survives_only_exact_numeric_tail_transitions() { + // Pins the shape-carried representation: the elements store is the + // default, and this test is about the property-shape machinery. + let _representation = + super::subclass_elements::ArraySubclassRepresentationGuard::shape_carried(); let _global = crate::gc::global_side_table_test_lock(); crate::object::array_tail_transition::test_clear(); let class_id = 0x0074_865b; @@ -738,6 +766,10 @@ fn array_subclass_named_prefix_token_survives_only_exact_numeric_tail_transition /// overwrite defeats the transition cache that made the tail mutation cheap. #[test] fn plain_array_element_shape_consumes_array_subclass_prefix_proof() { + // Pins the shape-carried representation: the elements store is the + // default, and this test is about the property-shape machinery. + let _representation = + super::subclass_elements::ArraySubclassRepresentationGuard::shape_carried(); let _global = crate::gc::global_side_table_test_lock(); crate::object::array_tail_transition::test_clear(); let class_id = 0x0074_8667; @@ -918,6 +950,10 @@ fn dense_array_subclass_tail_cache_preserves_a_1024_shape_lattice() { #[test] fn dense_array_subclass_tail_fast_path_declines_restricted_receivers() { + // Pins the shape-carried representation: the elements store is the + // default, and this test is about the property-shape machinery. + let _representation = + super::subclass_elements::ArraySubclassRepresentationGuard::shape_carried(); let _global = crate::gc::global_side_table_test_lock(); crate::object::array_tail_transition::test_clear(); let class_id = 0x0074_8658; @@ -955,6 +991,10 @@ fn dense_array_subclass_tail_fast_path_declines_restricted_receivers() { #[test] fn dense_array_subclass_tail_transition_edges_survive_moving_gc() { + // Pins the shape-carried representation: the elements store is the + // default, and this test is about the property-shape machinery. + let _representation = + super::subclass_elements::ArraySubclassRepresentationGuard::shape_carried(); let _copying_nursery = crate::gc::CopyingNurseryTestGuard::new(0); let _triggers = crate::gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers(); let _force_evacuation = crate::gc::knob_overrides::ForcedEvacuationTestGuard::on(); @@ -1010,6 +1050,10 @@ fn dense_array_subclass_tail_transition_edges_survive_moving_gc() { /// later loop clone would reinterpret the SSO bits as an f64 Number. #[test] fn packed_numeric_proof_is_retired_by_sso_index_overwrite() { + // Pins the shape-carried representation: the elements store is the + // default, and this test is about the property-shape machinery. + let _representation = + super::subclass_elements::ArraySubclassRepresentationGuard::shape_carried(); let class_id = 0x0074_8690; crate::object::js_register_class_parent(class_id, CLASS_ID_ARRAY); let obj = js_object_alloc(class_id, 2); @@ -1069,6 +1113,10 @@ fn packed_numeric_proof_is_retired_by_sso_index_overwrite() { #[test] fn packed_numeric_proof_survives_pointer_free_index_swap() { + // Pins the shape-carried representation: the elements store is the + // default, and this test is about the property-shape machinery. + let _representation = + super::subclass_elements::ArraySubclassRepresentationGuard::shape_carried(); let class_id = 0x0074_8692; crate::object::js_register_class_parent(class_id, CLASS_ID_ARRAY); let obj = js_object_alloc(class_id, 2); @@ -1108,6 +1156,10 @@ fn packed_numeric_proof_survives_pointer_free_index_swap() { #[test] fn fused_ecs_guard_requires_distinct_owning_u32_columns_and_exact_entity_ids() { + // Pins the shape-carried representation: the elements store is the + // default, and this test is about the property-shape machinery. + let _representation = + super::subclass_elements::ArraySubclassRepresentationGuard::shape_carried(); let class_id = 0x0074_8691; crate::object::js_register_class_parent(class_id, CLASS_ID_ARRAY); let obj = js_object_alloc(class_id, 2); From bd7f2b52f7e6e4316ea4b5f075664303a87463de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 20:23:13 +0200 Subject: [PATCH 2/2] chore(changelog): name the fragment for its PR --- ...lements-default.md => 8974-array-subclass-elements-default.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{0000-array-subclass-elements-default.md => 8974-array-subclass-elements-default.md} (100%) diff --git a/changelog.d/0000-array-subclass-elements-default.md b/changelog.d/8974-array-subclass-elements-default.md similarity index 100% rename from changelog.d/0000-array-subclass-elements-default.md rename to changelog.d/8974-array-subclass-elements-default.md