diff --git a/changelog.d/8849-array-length-truncation.md b/changelog.d/8849-array-length-truncation.md new file mode 100644 index 0000000000..0245f9f0b0 --- /dev/null +++ b/changelog.d/8849-array-length-truncation.md @@ -0,0 +1,6 @@ +Strict writes to a statically proven Array's `length` now retain their ArraySetLength lowering, +and ordinary dense truncation clears discarded slots in one guarded runtime region instead of +performing descriptor and side-table deletion work for every element. The generic path remains for +sloppy, explicit-receiver, sparse, and descriptor-bearing cases. On the unchanged codehz/ecs +15k-command workload, an 11-pair Apple-silicon cohort improved the median from 31.541 ms to +28.354 ms (10.03%, 11/11 wins, 22/22 semantic-oracle passes). diff --git a/crates/perry-codegen/src/expr/call_return_array_index_tests.rs b/crates/perry-codegen/src/expr/call_return_array_index_tests.rs index 68802a99f7..9812dd5b3c 100644 --- a/crates/perry-codegen/src/expr/call_return_array_index_tests.rs +++ b/crates/perry-codegen/src/expr/call_return_array_index_tests.rs @@ -76,6 +76,19 @@ fn store_class(receiver_selector: i64) -> Class { strict: true, })], ); + let clear = function( + 4, + "clear", + Vec::new(), + Type::Void, + vec![Stmt::Expr(Expr::PutValueSet { + target: Box::new(call_get_data(0)), + key: Box::new(Expr::String("length".to_string())), + value: Box::new(Expr::Integer(0)), + receiver: Box::new(call_get_data(receiver_selector)), + strict: true, + })], + ); Class { id: 1, name: "Store".to_string(), @@ -87,7 +100,7 @@ fn store_class(receiver_selector: i64) -> Class { heritage_lexically_shadowed: false, fields: Vec::new(), constructor: None, - methods: vec![get_data, write], + methods: vec![get_data, write, clear], getters: Vec::new(), setters: Vec::new(), static_accessor_names: Vec::new(), @@ -128,6 +141,16 @@ fn write_method_ir(ir: &str) -> &str { &method_and_rest[..end + 3] } +fn clear_method_ir(ir: &str) -> &str { + let signature = "define double @perry_method_call_return_array_put_value_ts__Store__clear("; + let start = ir.find(signature).expect("clear method is present in IR"); + let method_and_rest = &ir[start..]; + let end = method_and_rest + .find("\n}\n") + .expect("clear method has a closing brace"); + &method_and_rest[..end + 3] +} + #[test] fn same_call_returned_array_uses_array_index_store_and_evaluates_receiver_once() { let ir = compile_store_ir(0); @@ -152,6 +175,43 @@ fn same_call_returned_array_uses_array_index_store_and_evaluates_receiver_once() ); } +#[test] +fn same_call_returned_array_uses_array_length_store_and_evaluates_receiver_once() { + let ir = compile_store_ir(0); + let clear_ir = clear_method_ir(&ir); + + assert!( + clear_ir.contains("call void @js_array_set_length_strict("), + "a call with an Array return type must use ArraySetLength semantics:\n{clear_ir}" + ); + assert_eq!( + clear_ir + .matches("@perry_method_call_return_array_put_value_ts__Store__getData") + .count(), + 1, + "the duplicated PutValue target/receiver trees represent one source evaluation:\n{clear_ir}" + ); + assert!( + !clear_ir.contains("@js_put_value_set_ic_miss("), + "a proven Array length write must not retain the generic property PIC:\n{clear_ir}" + ); +} + +#[test] +fn distinct_call_returned_array_length_receiver_stays_on_explicit_receiver_path() { + let ir = compile_store_ir(1); + let clear_ir = clear_method_ir(&ir); + + assert!( + !clear_ir.contains("call void @js_array_set_length_strict("), + "different target and receiver expressions must not collapse to one Array write:\n{clear_ir}" + ); + assert!( + clear_ir.contains("@js_put_value_set"), + "the explicit-receiver PutValue fallback must remain present:\n{clear_ir}" + ); +} + #[test] fn distinct_call_receiver_stays_on_explicit_receiver_put_value_path() { let ir = compile_store_ir(1); diff --git a/crates/perry-codegen/src/expr/proxy_reflect.rs b/crates/perry-codegen/src/expr/proxy_reflect.rs index 0d3bca3bc4..75ae8df047 100644 --- a/crates/perry-codegen/src/expr/proxy_reflect.rs +++ b/crates/perry-codegen/src/expr/proxy_reflect.rs @@ -349,6 +349,23 @@ fn put_value_static_property_fast_path( let Expr::String(property) = key else { return None; }; + // Source-level `arr.length = value` lowers to `PutValueSet`, while the + // Array-exotic length implementation lives in `PropertySet::lower`. + // Preserve that statically proven receiver contract here just as + // `put_value_index_fast_path` below does for Array index writes. The two + // receiver trees represent the one source evaluation, so use the shared + // structural identity check and let `PropertySet::lower` evaluate it once. + // + // Only strict writes may take this route: the existing Array length arm + // calls `js_array_set_length_strict`, whereas a rejected sloppy PutValue + // must remain a silent no-op through the generic strict-aware runtime. + if strict + && property == "length" + && same_put_value_receiver_expr(target, receiver) + && is_array_expr(ctx, target) + { + return Some(property.clone()); + } // #6542: this fast path lowers to `js_object_set_field_by_name`, which has // no `strict` parameter and throws unconditionally when the field is // non-writable (frozen/sealed object, `writable: false` descriptor). That diff --git a/crates/perry-runtime/src/array/header.rs b/crates/perry-runtime/src/array/header.rs index 6c21dfeef3..389806463b 100644 --- a/crates/perry-runtime/src/array/header.rs +++ b/crates/perry-runtime/src/array/header.rs @@ -475,6 +475,25 @@ pub(crate) unsafe fn array_named_property_get_by_name( }) } +/// Whether this Array owns any side-table properties. +/// +/// Numeric properties normally live in dense element storage, but a far +/// sparse index can enter this table and later fall below a grown capacity. +/// Bulk element operations use this predicate to decline a dense-only path +/// instead of leaving that second representation observable. +#[inline] +pub(crate) unsafe fn array_has_named_properties(arr: *const ArrayHeader) -> bool { + let arr = clean_arr_ptr(arr); + if arr.is_null() { + return false; + } + ARRAY_NAMED_PROPS.with(|m| { + m.borrow() + .get(&(arr as usize)) + .is_some_and(|props| !props.is_empty()) + }) +} + pub(crate) unsafe fn array_named_property_get( arr: *const ArrayHeader, key: *const crate::StringHeader, diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index 488bf0c444..c72e6cda5a 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -212,8 +212,8 @@ pub(crate) use self::alloc::array_length_from_property_value_or_throw; pub(crate) use self::alloc::{js_array_from_arraylike, js_array_from_string_codepoints}; pub(crate) use self::flat_clone::{dense_spread_copy, dense_spread_source, flattenable_array_ptr}; pub(crate) use self::header::{ - array_byte_size, array_is_frozen, array_is_sealed_or_no_extend, array_named_property_delete, - array_named_property_delete_by_name, array_named_property_get, + array_byte_size, array_has_named_properties, array_is_frozen, array_is_sealed_or_no_extend, + array_named_property_delete, array_named_property_delete_by_name, array_named_property_get, array_named_property_get_by_name, array_named_property_has, array_named_property_names, array_named_property_set, array_numeric_raw_f64_get, array_numeric_raw_f64_push_inbounds, array_numeric_raw_f64_set_inbounds, array_object_flags, array_object_flags_from_tag, diff --git a/crates/perry-runtime/src/array/push_pop.rs b/crates/perry-runtime/src/array/push_pop.rs index 78b76b889a..c3625df647 100644 --- a/crates/perry-runtime/src/array/push_pop.rs +++ b/crates/perry-runtime/src/array/push_pop.rs @@ -981,6 +981,28 @@ pub extern "C" fn js_array_set_length(arr: *mut ArrayHeader, new_length: f64) { // table. Delete them first, in the same descending order required // by ArraySetLength, then visit the allocated dense prefix. let capacity = (*arr).capacity; + // With no indexed descriptors and no side-table properties, every + // own index in the truncated suffix is an ordinary dense slot. + // ArraySetLength has no observable per-index operation in this + // case, so clear the suffix in one runtime region and rebuild the + // live-prefix GC layout once. This preserves the holes required if + // the array grows again without paying String construction and + // three descriptor/expando probes for every removed element. + if flags & crate::gc::OBJ_FLAG_ARRAY_DESCRIPTORS == 0 + && cur <= capacity + && !array_has_named_properties(arr) + { + let elements = (arr as *mut u8).add(std::mem::size_of::()) as *mut u64; + for i in n..cur { + // GC_STORE_AUDIT(BARRIERED): the suffix becomes unreachable + // when length is published below; rebuild_array_layout then + // rebuilds the complete live-prefix layout/barrier state. + ptr::write(elements.add(i as usize), crate::value::TAG_HOLE); + } + (*arr).length = n; + rebuild_array_layout(arr); + return; + } if cur > capacity { let mut sparse_indices: Vec = array_named_property_names(arr, false) .into_iter() diff --git a/crates/perry-runtime/src/array/tests.rs b/crates/perry-runtime/src/array/tests.rs index 2a595eb833..7ec40a3537 100644 --- a/crates/perry-runtime/src/array/tests.rs +++ b/crates/perry-runtime/src/array/tests.rs @@ -1232,6 +1232,37 @@ fn large_length_growth_stays_logically_sparse() { assert_eq!(array_spec_get(arr, 0), 1.0); } +#[test] +fn dense_length_truncation_clears_slots_and_stale_named_indices() { + let mut dense = js_array_alloc(4); + dense = js_array_push_f64(dense, 10.0); + dense = js_array_push_f64(dense, 20.0); + dense = js_array_push_f64(dense, 30.0); + + js_array_set_length(dense, 0.0); + assert_eq!(js_array_length(dense), 0); + js_array_set_length(dense, 3.0); + for index in 0..3 { + assert_eq!( + array_spec_get(dense, index).to_bits(), + crate::value::TAG_UNDEFINED + ); + } + + // A numeric property can live in ARRAY_NAMED_PROPS after a sparse index's + // backing later grows past it. The dense bulk path must decline whenever + // that second representation is present, and the ordinary deletion walk + // must clear both representations. + let key = crate::string::js_string_from_bytes(b"2".as_ptr(), 1); + unsafe { array_named_property_set(dense, key, 99.0) }; + js_array_set_length(dense, 0.0); + js_array_set_length(dense, 3.0); + assert_eq!( + array_spec_get(dense, 2).to_bits(), + crate::value::TAG_UNDEFINED + ); +} + #[test] fn test_numeric_array_layout_immutable_helpers_preserve_or_downgrade() { let values = [10.0, 2.0, 30.0, 40.0];