Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/8974-array-subclass-elements-default.md
Original file line number Diff line number Diff line change
@@ -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.
63 changes: 59 additions & 4 deletions crates/perry-runtime/src/array/subclass_elements.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> = 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<Option<bool>> =
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<bool>);

#[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).
///
Expand Down
7 changes: 6 additions & 1 deletion crates/perry-runtime/src/array/subclass_elements_tests.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
52 changes: 52 additions & 0 deletions crates/perry-runtime/src/array/subclass_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading