From 9b722c2fee464d45847e2d029acd2b0b204221ed Mon Sep 17 00:00:00 2001 From: Ralph Date: Fri, 17 Jul 2026 05:31:23 -0700 Subject: [PATCH 1/2] perf(runtime,codegen): fast paths for DataView accessors, Array.concat, and regex match materialization (#6386) Three hot operations lowered to opaque generic helpers and ran 28-214x slower than Node (#6386). Each gets a guarded fast path that preserves the generic path's observable semantics, with monotonic gates that re-open the spec-shaped path the moment any exotica is installed: DataView get*/set* (9.5x, 449ms -> 47ms on the issue repro): - codegen lowers accessor calls on statically-typed DataView receivers (and unknown-typed receivers with accessor-family names) to new js_data_view_{get,set}_direct entries instead of the dynamic dispatch tower; the entries re-validate the receiver against DATA_VIEW_REGISTRY and fall back to js_native_call_method (same #5525 guarded shape) - buffer own-prop shadow probe is skipped via a monotonic "own props ever stored" gate (was a global mutex per call) - to_byte_offset/to_number fast-path genuine untagged doubles - VIEW_REGISTRY/BACKING_TO_VIEWS switch to the address-keyed PtrHashMap - js_set_call_location stores raw (ptr,len,line) instead of building a lossy String per dynamic dispatch (debug-symbols builds) Array.prototype.concat (13.6x, 1334ms -> 98ms): - append_spread_array: dense hole-free plain-array sources bulk-copy via one pre-grow + copy_nonoverlapping + rebuild_array_layout_exact (mirrors js_array_concat's audited pattern) instead of js_array_clone + per-element guarded pushes - the species-default result is pre-sized from pure header peeks (capacity is unobservable), eliminating grow-doubling churn - ArraySpeciesCreate: a plain dense array with no own "constructor" (named-props probe + a monotonic "constructor accessor ever installed" gate) resolves Default without the key-string allocation + property walk; behavior verified identical against pre-change main, which does not model prototype-level Array.prototype.constructor mutation - per-argument @@isConcatSpreadable reads are skipped for non-proxy values behind a monotonic gate flipped by every symbol install funnel (instance stores, accessors, defineProperty attrs, class statics, class computed methods); the gate peek never allocates - an all-dense variadic pass copies everything with a single layout rebuild when the gate is closed and no source is exotic Regex match/exec materialization (3.4x, 856ms -> 254ms): - index/input/groups land in the named-props side table via ONE batched probe with &'static str keys (ArrayNamedProperty.name is now Cow<'static, str>) instead of three js_array_set_string_key ladders with three fresh key-string allocations - .input re-boxes the subject StringHeader (demoted to shared) instead of copying the whole subject per match - capture slots store via the GC_STORE_AUDIT(INIT) layout-only helper with one exact layout/barrier rebuild after the loop - ARRAY_NAMED_PROPS switches to the address-keyed PtrHashMap The new gap test pins the fast paths AND their kick-back conditions (mid-run gate flips, runtime type violations, shadowed methods, holes, species mutation via own constructor). Own symbol props on array instances remain a pre-existing concat gap (unchanged, noted in test). Fixes #6386. Co-Authored-By: Claude Fable 5 --- .../src/lower_call/console_promise.rs | 22 ++ .../src/lower_call/dataview_intrinsic.rs | 114 +++++++ crates/perry-codegen/src/lower_call/mod.rs | 1 + .../src/runtime_decls/strings.rs | 13 + crates/perry-runtime/src/array/from_concat.rs | 308 +++++++++++++++++- crates/perry-runtime/src/array/header.rs | 52 ++- crates/perry-runtime/src/array/mod.rs | 7 +- crates/perry-runtime/src/array/species.rs | 50 ++- crates/perry-runtime/src/buffer/dataview.rs | 185 ++++++++++- crates/perry-runtime/src/buffer/mod.rs | 4 +- crates/perry-runtime/src/buffer/own_props.rs | 15 + crates/perry-runtime/src/buffer/view.rs | 13 +- crates/perry-runtime/src/error.rs | 23 +- .../object/class_registry/parent_static.rs | 1 + .../src/object/descriptor_state.rs | 21 ++ crates/perry-runtime/src/object/mod.rs | 16 +- crates/perry-runtime/src/regex/exec.rs | 31 +- crates/perry-runtime/src/regex/exec_array.rs | 44 +++ .../perry-runtime/src/regex/match_string.rs | 31 +- crates/perry-runtime/src/symbol.rs | 44 +++ crates/perry-runtime/src/symbol/accessors.rs | 1 + crates/perry-runtime/src/symbol/properties.rs | 2 + ...ap_6386_dataview_concat_regex_fastpaths.ts | 130 ++++++++ 23 files changed, 1061 insertions(+), 67 deletions(-) create mode 100644 crates/perry-codegen/src/lower_call/dataview_intrinsic.rs create mode 100644 test-files/test_gap_6386_dataview_concat_regex_fastpaths.ts diff --git a/crates/perry-codegen/src/lower_call/console_promise.rs b/crates/perry-codegen/src/lower_call/console_promise.rs index dd20561bc0..3bacea13c5 100644 --- a/crates/perry-codegen/src/lower_call/console_promise.rs +++ b/crates/perry-codegen/src/lower_call/console_promise.rs @@ -797,6 +797,28 @@ pub fn try_lower_native_method_str_dispatch( return Ok(Some(materialized)); } } + // #6386 fast path: `dv.getFloat64(off, le)` / `dv.setInt32(off, v)` + // lowers to one `js_data_view_{get,set}_direct` call instead of + // the generic dispatch tower. Fires for a statically-typed + // DataView receiver AND for an unknown-typed receiver (a mutable + // `var v = new DataView(b)` is widened to Any by the local-type + // fixpoint) whose method name matches the accessor family — the + // runtime entry re-validates the receiver against the DataView + // registry and re-enters `js_native_call_method` otherwise, so a + // non-DataView receiver that happens to share the method name + // keeps its generic dispatch semantics (same #5525 guarded- + // fast-path shape as typed-array index access). + if matches!(class_name_opt.as_deref(), Some("DataView") | None) { + if let Some(reg) = super::dataview_intrinsic::try_emit_data_view_accessor( + ctx, + object, + property, + args, + call_byte_offset, + )? { + return Ok(Some(reg)); + } + } let recv_box = lower_expr(ctx, object)?; let mut lowered_args: Vec = Vec::with_capacity(args.len()); for a in args { diff --git a/crates/perry-codegen/src/lower_call/dataview_intrinsic.rs b/crates/perry-codegen/src/lower_call/dataview_intrinsic.rs new file mode 100644 index 0000000000..2da72e7835 --- /dev/null +++ b/crates/perry-codegen/src/lower_call/dataview_intrinsic.rs @@ -0,0 +1,114 @@ +//! #6386: direct lowering for DataView accessor method calls. +//! +//! `dv.getFloat64(off, le)` / `dv.setInt32(off, v)` on a receiver whose +//! STATIC type is `DataView` previously lowered to the fully generic +//! `js_typed_feedback_native_call_method_by_id` tower — per call: a method-id +//! resolution, a typed-feedback observation, an args `Vec` + handle-scope +//! setup, then the buffer-registry dispatch ladder (`is_registered_buffer` → +//! own-prop shadow probe → `is_data_view` → suffix re-parse). This lowers the +//! same calls to one `js_data_view_{get,set}_direct` call carrying the +//! pre-resolved element-kind code. +//! +//! The runtime entry re-checks the receiver (a variable whose static type +//! was violated at runtime, a shadowed method, exotica) and falls back to the +//! generic dispatcher, so this is a pure fast path — semantics unchanged. + +use anyhow::Result; +use perry_hir::Expr; + +use crate::expr::{lower_expr, FnCtx}; +use crate::types::{DOUBLE, I32}; + +/// Classify a DataView accessor method name: `Some((is_set, kind_code))` for +/// the `get*`/`set*` numeric family. `kind_code` is the ABI contract with +/// `DataViewKind` in `perry-runtime/src/buffer/dataview.rs` (`repr(i32)` +/// discriminants) — keep the two in sync. +fn classify_data_view_accessor(method: &str) -> Option<(bool, i32)> { + let (is_set, suffix) = if let Some(s) = method.strip_prefix("get") { + (false, s) + } else if let Some(s) = method.strip_prefix("set") { + (true, s) + } else { + return None; + }; + let kind_code = match suffix { + "Int8" => 0, + "Uint8" => 1, + "Int16" => 2, + "Uint16" => 3, + "Int32" => 4, + "Uint32" => 5, + "Float32" => 6, + "Float64" => 7, + "BigInt64" => 8, + "BigUint64" => 9, + _ => return None, + }; + Some((is_set, kind_code)) +} + +/// Try to lower `object.(args)` as a direct DataView accessor call. +/// Returns `Ok(None)` when the method/arity doesn't match the direct form — +/// the generic dispatch path then handles it (missing REQUIRED arguments stay +/// on the generic path so its argument-defaulting behavior is preserved +/// exactly; extra arguments beyond the accessor's arity likewise). +pub(super) fn try_emit_data_view_accessor( + ctx: &mut FnCtx<'_>, + object: &Expr, + property: &str, + args: &[Expr], + call_byte_offset: u32, +) -> Result> { + let Some((is_set, kind_code)) = classify_data_view_accessor(property) else { + return Ok(None); + }; + let (min_args, max_args) = if is_set { (2, 3) } else { (1, 2) }; + if args.len() < min_args || args.len() > max_args { + return Ok(None); + } + let recv = lower_expr(ctx, object)?; + let mut lowered: Vec = Vec::with_capacity(args.len()); + for a in args { + lowered.push(lower_expr(ctx, a)?); + } + // Absent littleEndian lowers to undefined — the runtime evaluates its + // truthiness exactly like the generic path's `truthy(args[2])`. + let undef = ctx + .block() + .bitcast_i64_to_double(crate::nanbox::TAG_UNDEFINED_I64); + // The accessors can throw (RangeError on an out-of-bounds offset, the + // offset's `valueOf`) — record the call location for the error message. + crate::expr::calls::emit_call_location_at(ctx, call_byte_offset); + let argc = args.len().to_string(); + let kind = kind_code.to_string(); + let blk = ctx.block(); + let result = if is_set { + let little = lowered.get(2).unwrap_or(&undef); + blk.call( + DOUBLE, + "js_data_view_set_direct", + &[ + (DOUBLE, &recv), + (DOUBLE, &lowered[0]), + (DOUBLE, &lowered[1]), + (DOUBLE, little), + (I32, &kind), + (I32, &argc), + ], + ) + } else { + let little = lowered.get(1).unwrap_or(&undef); + blk.call( + DOUBLE, + "js_data_view_get_direct", + &[ + (DOUBLE, &recv), + (DOUBLE, &lowered[0]), + (DOUBLE, little), + (I32, &kind), + (I32, &argc), + ], + ) + }; + Ok(Some(result)) +} diff --git a/crates/perry-codegen/src/lower_call/mod.rs b/crates/perry-codegen/src/lower_call/mod.rs index 53ac3f64e4..530641138d 100644 --- a/crates/perry-codegen/src/lower_call/mod.rs +++ b/crates/perry-codegen/src/lower_call/mod.rs @@ -38,6 +38,7 @@ mod builtin_table_gate; mod capture_writeback; mod closure_analysis; mod console_promise; +mod dataview_intrinsic; mod early_branches; mod event_target; mod extern_func; diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 40b0befb62..682cec98b3 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -765,6 +765,19 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_util_types_is_map_iterator", DOUBLE, &[DOUBLE]); module.declare_function("js_util_types_is_set_iterator", DOUBLE, &[DOUBLE]); module.declare_function("js_data_view_new", DOUBLE, &[DOUBLE, DOUBLE, DOUBLE]); + // #6386: direct DataView accessor entries for statically-typed receivers + // (kind codes = `DataViewKind` repr(i32) discriminants; trailing i32 is + // the source-level argc, forwarded for the generic-dispatch fallback). + module.declare_function( + "js_data_view_get_direct", + DOUBLE, + &[DOUBLE, DOUBLE, DOUBLE, I32, I32], + ); + module.declare_function( + "js_data_view_set_direct", + DOUBLE, + &[DOUBLE, DOUBLE, DOUBLE, DOUBLE, I32, I32], + ); module.declare_function("js_getenv", I64, &[I64]); module.declare_function("js_getenv_value", DOUBLE, &[I64]); // #1344: process.env.X = v / delete process.env.X. diff --git a/crates/perry-runtime/src/array/from_concat.rs b/crates/perry-runtime/src/array/from_concat.rs index 36626fabe8..846386110d 100644 --- a/crates/perry-runtime/src/array/from_concat.rs +++ b/crates/perry-runtime/src/array/from_concat.rs @@ -214,8 +214,12 @@ pub extern "C" fn js_array_concat_variadic( // a non-constructor species (test262 concat/create-ctor-poisoned, // create-ctor-non-object, create-non-array). let recv_value = f64::from_bits(JSValue::pointer(recv as *const u8).bits()); + // #6386: size the result once. The hint is pure header peeks + // (unobservable), so it runs before the observable species resolution + // without reordering anything the spec sequences. + let cap_hint = unsafe { concat_capacity_hint(recv, args_ptr, count) }; let (result_box, result_is_plain) = unsafe { - let b = crate::array::species::array_species_create(recv_value, 0); + let b = crate::array::species::array_species_create_with_capacity(recv_value, 0, cap_hint); (b, crate::array::species::species_result_is_plain_array(b)) }; let result = if result_is_plain { @@ -223,8 +227,19 @@ pub extern "C" fn js_array_concat_variadic( } else { // Custom species container: build the elements in a plain staging // array first, then CreateDataProperty them onto the container below. - js_array_alloc(0) + js_array_alloc(cap_hint) }; + // #6386 all-dense bulk path: when the spreadable gate is closed and every + // source is a plain dense hole-free array (or a non-pointer single + // value), fill the pre-sized plain result with one copy pass and ONE + // layout/barrier rebuild — no per-source rebuild, no spreadable reads, + // no growth. Falls through (result still empty) when anything exotic + // shows up. + if result_is_plain && !crate::symbol::concat_spreadable_symbol_ever_set() { + if let Some(out) = unsafe { try_concat_all_dense(result, recv, args_ptr, count) } { + return out; + } + } // The receiver itself is always spread (it's the array on which `.concat` // was invoked). Materialize a clone to read its elements safely. let result = append_spread_array(result, recv as *const ArrayHeader); @@ -308,6 +323,18 @@ pub(crate) fn append_concat_arg(result: *mut ArrayHeader, value: f64) -> *mut Ar /// when the property is a defined boolean (using JS truthiness), or `None` when /// the property is absent/undefined (→ default behavior). fn read_concat_spreadable(value: f64) -> Option { + // #6386 fast path: while no `Symbol.isConcatSpreadable` property has + // ever been installed process-wide (monotonic gate over every symbol + // install funnel), the lookup below is guaranteed to produce undefined + // for any non-proxy value — and to run no user code — so skip the + // symbol-table ladder (a mutex acquisition per argument). Proxies are + // excluded: their `get` trap can materialize the property without any + // install having happened. + if !crate::symbol::concat_spreadable_symbol_ever_set() + && crate::proxy::js_proxy_is_proxy(value) == 0 + { + return None; + } let sym = crate::symbol::well_known_symbol("isConcatSpreadable"); if sym.is_null() { return None; @@ -728,11 +755,288 @@ pub fn array_of_full(c: f64, vals: &[f64]) -> f64 { result } +/// Peek a source's dense length for the concat capacity estimate: `Some(len)` +/// for a genuine plain `ArrayHeader`, `Some(0)` for null, `None` for anything +/// whose element count this can't cheaply know (proxy, lazy array, set/map/ +/// typed-array/buffer reading as array-typed). Pure header reads — never runs +/// user code. +unsafe fn peek_plain_array_len(arr: *const ArrayHeader) -> Option { + if crate::array::array_ptr_as_proxy(arr).is_some() { + return None; + } + let arr = clean_arr_ptr(arr); + if arr.is_null() { + return Some(0); + } + let raw = arr as usize; + if raw < crate::gc::GC_HEADER_SIZE + 0x1000 { + return None; + } + let hdr = (raw as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; + if (*hdr).obj_type != crate::gc::GC_TYPE_ARRAY { + return None; + } + Some((*arr).length) +} + +/// Capacity hint for the concat result (#6386): sum of the receiver's and the +/// array arguments' dense lengths (1 slot for anything un-peekable). Purely +/// a hint — under-estimates are backstopped by `js_array_grow`, so exotic +/// cases (array-likes, species mutation from a poisoned getter) stay correct, +/// merely unpre-sized. +unsafe fn concat_capacity_hint(recv: *const ArrayHeader, args_ptr: *const f64, count: i32) -> u32 { + let mut total: u64 = peek_plain_array_len(recv).unwrap_or(0) as u64; + if !args_ptr.is_null() && count > 0 { + for i in 0..count as usize { + let bits = (*args_ptr.add(i)).to_bits(); + if JSValue::from_bits(bits).is_pointer() { + let ptr = (bits & 0x0000_FFFF_FFFF_FFFF) as *const ArrayHeader; + total += peek_plain_array_len(ptr).unwrap_or(1) as u64; + } else { + total += 1; + } + } + } + total.min(16_000_000) as u32 +} + +/// Validate one concat source for the all-dense bulk path: a genuine plain +/// dense hole-free `ArrayHeader`. `Some((ptr, len))` on success (null → +/// `Some((null, 0))`), `None` for anything the bulk path must not touch. +/// Pure reads — runs no user code, allocates nothing. +unsafe fn dense_concat_array_source(src: *const ArrayHeader) -> Option<(*const ArrayHeader, u32)> { + if crate::array::array_ptr_as_proxy(src).is_some() { + return None; + } + let src = clean_arr_ptr(src); + if src.is_null() { + return Some((src, 0)); + } + let raw = src as usize; + if raw < crate::gc::GC_HEADER_SIZE + 0x1000 { + return None; + } + let hdr = (raw as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; + if (*hdr).obj_type != crate::gc::GC_TYPE_ARRAY { + return None; + } + if crate::set::is_registered_set(raw) + || crate::map::is_registered_map(raw) + || crate::typedarray::lookup_typed_array_kind(raw).is_some() + || crate::buffer::is_registered_buffer(raw) + { + return None; + } + let len = (*src).length; + if len > (*src).capacity { + return None; + } + let elems = (src as *const u8).add(std::mem::size_of::()) as *const f64; + for i in 0..len as usize { + if (*elems.add(i)).to_bits() == crate::value::TAG_HOLE { + return None; + } + } + Some((src, len)) +} + +/// #6386 all-dense bulk concat. Preconditions established by the caller: the +/// result is a freshly allocated plain array (length 0) and the +/// `isConcatSpreadable` gate is closed (so skipping the per-argument +/// spreadable reads is unobservable). Validates every source in a first +/// pass (pure reads), then — only if the pre-sized result can hold the total +/// WITHOUT growing, so no allocation and hence no GC can occur mid-copy — +/// copies everything and performs a single exact layout/barrier rebuild. +/// Returns `None` with the result untouched (length still 0) when any +/// precondition fails; the caller's spec-shaped per-source flow takes over. +unsafe fn try_concat_all_dense( + result: *mut ArrayHeader, + recv: *const ArrayHeader, + args_ptr: *const f64, + count: i32, +) -> Option<*mut ArrayHeader> { + let count = count.max(0) as usize; + let args: &[f64] = if args_ptr.is_null() || count == 0 { + &[] + } else { + std::slice::from_raw_parts(args_ptr, count) + }; + // Pass 1: validate every source and total the lengths. + let (recv_src, recv_len) = dense_concat_array_source(recv)?; + let mut total: u64 = recv_len as u64; + for &arg in args { + let bits = arg.to_bits(); + if bits >> 48 == 0x7FFD { + let (_, len) = + dense_concat_array_source((bits & 0x0000_FFFF_FFFF_FFFF) as *const ArrayHeader)?; + total += len as u64; + } else { + // Non-pointer value (number / string-by-tag / bool / undefined / + // null / bigint): exactly one result slot. + total += 1; + } + } + if total > 16_000_000 { + return None; + } + let total = total as u32; + if total > (*result).capacity { + return None; + } + // Pass 2: copy. Nothing below allocates, so no GC can move a source or + // the result mid-copy, which is what makes the single deferred rebuild + // sound. + let dst = (result as *mut u8).add(std::mem::size_of::()) as *mut f64; + let mut off: usize = 0; + let mut copy_array = |src: *const ArrayHeader, len: u32, off: &mut usize| { + if len == 0 { + return; + } + let elems = (src as *const u8).add(std::mem::size_of::()) as *const f64; + // GC_STORE_AUDIT(BARRIERED): all-dense concat bulk copy; one exact + // layout/barrier rebuild follows after all sources are copied. + std::ptr::copy_nonoverlapping(elems, dst.add(*off), len as usize); + // Same shared-demote `js_array_push_f64` performs per element, so a + // later in-place mutation of a source string local can't edit the + // stored element. + for i in *off..*off + len as usize { + let v = *dst.add(i); + if v.to_bits() >> 48 == 0x7FFF { + crate::string::js_string_addref_if_heap_string(v); + } + } + *off += len as usize; + }; + copy_array(recv_src, recv_len, &mut off); + for &arg in args { + let bits = arg.to_bits(); + if bits >> 48 == 0x7FFD { + // Re-validated cheaply: pass 1 proved this resolves to a dense + // array; nothing has run since that could change it. + let (src, len) = + dense_concat_array_source((bits & 0x0000_FFFF_FFFF_FFFF) as *const ArrayHeader)?; + copy_array(src, len, &mut off); + } else { + if bits >> 48 == 0x7FFF { + crate::string::js_string_addref_if_heap_string(arg); + } + std::ptr::write(dst.add(off), arg); + off += 1; + } + } + (*result).length = total; + crate::array::rebuild_array_layout_exact(result); + Some(result) +} + +/// Bulk fast path for `append_spread_array` (#6386): a plain, dense, +/// hole-free `ArrayHeader` source appended onto a plain result with one +/// pre-grow + bulk element copy, mirroring `js_array_concat`'s audited +/// bulk-copy pattern (`concat_reverse.rs`). Replaces a full `js_array_clone` +/// of the source plus per-element `js_array_push_f64` (each doing proxy / +/// frozen / capacity / barrier work) — the dominant cost of `a.concat(b)` on +/// dense arrays. Returns `None` when any precondition fails so the caller +/// falls back to the spec-shaped loop below. +unsafe fn try_append_spread_array_dense( + result: *mut ArrayHeader, + src: *const ArrayHeader, +) -> Option<*mut ArrayHeader> { + // A masked proxy id is not a dereferenceable ArrayHeader. + if crate::array::array_ptr_as_proxy(src).is_some() { + return None; + } + let src = clean_arr_ptr(src); + if src.is_null() { + return Some(result); + } + let raw = src as usize; + if raw < crate::gc::GC_HEADER_SIZE + 0x1000 { + return None; + } + // Only a genuine dense array: sets/maps/typed-arrays/buffers/lazy arrays + // materialize element values through `js_array_clone` on the slow path. + let hdr = (raw as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; + if (*hdr).obj_type != crate::gc::GC_TYPE_ARRAY { + return None; + } + if crate::set::is_registered_set(raw) + || crate::map::is_registered_map(raw) + || crate::typedarray::lookup_typed_array_kind(raw).is_some() + || crate::buffer::is_registered_buffer(raw) + { + return None; + } + let src_len = (*src).length; + if src_len == 0 { + return Some(result); + } + if src_len > (*src).capacity { + return None; + } + let result = crate::array::clean_arr_ptr_mut(result); + if result.is_null() || std::ptr::eq(result as *const ArrayHeader, src) { + return None; + } + // The result is freshly allocated by the concat entry points, but a + // sealed/frozen dest would make `js_array_grow` return it un-grown and + // the bulk copy would overflow its capacity — keep the guard explicit. + if crate::array::array_is_frozen(result) || crate::array::array_is_sealed_or_no_extend(result) { + return None; + } + // Single pass: holes need the spec `HasProperty`/`Get` reads (inherited + // elements, side-table entries) — punt those to the slow path. Heap + // strings get the same shared-demote `js_array_push_f64` performs, so a + // later mutation of the source local can't edit the stored element. + let src_elems = (src as *const u8).add(std::mem::size_of::()) as *const f64; + for i in 0..src_len as usize { + let v = *src_elems.add(i); + if v.to_bits() == crate::value::TAG_HOLE { + return None; + } + if v.to_bits() >> 48 == 0x7FFF { + crate::string::js_string_addref_if_heap_string(v); + } + } + let dest_len = (*result).length; + let new_len = dest_len.checked_add(src_len)?; + let (result, src) = if new_len > (*result).capacity { + // Growing can allocate → GC can run. `result` is rooted inside + // `js_array_grow`; root `src` too (it may be an unrooted snapshot, + // e.g. from `array_subclass_dense_snapshot`) and re-resolve both. + let scope = crate::gc::RuntimeHandleScope::new(); + let src_handle = scope.root_raw_const_ptr(src); + let grown = crate::array::js_array_grow(result, new_len); + ( + grown, + clean_arr_ptr(src_handle.get_raw_const_ptr::()), + ) + } else { + (result, src) + }; + if result.is_null() || src.is_null() { + return None; + } + let src_elems = (src as *const u8).add(std::mem::size_of::()) as *const f64; + let dst_elems = (result as *mut u8).add(std::mem::size_of::()) as *mut f64; + // GC_STORE_AUDIT(BARRIERED): concat bulk copy is followed by exact layout/barrier rebuild. + std::ptr::copy_nonoverlapping( + src_elems, + dst_elems.add(dest_len as usize), + src_len as usize, + ); + (*result).length = new_len; + crate::array::rebuild_array_layout_exact(result); + Some(result) +} + /// Append every element of the (already-materializable) source array `src` /// into `result`, returning the (possibly reallocated) result. `src` is /// materialized via `js_array_clone` so sets/maps/typed-arrays/buffers spread /// to their element values, matching `[...x]`. fn append_spread_array(result: *mut ArrayHeader, src: *const ArrayHeader) -> *mut ArrayHeader { + if let Some(out) = unsafe { try_append_spread_array_dense(result, src) } { + return out; + } let materialized = js_array_clone(src); let materialized = clean_arr_ptr(materialized); if materialized.is_null() { diff --git a/crates/perry-runtime/src/array/header.rs b/crates/perry-runtime/src/array/header.rs index c17f56e3ee..a679f84027 100644 --- a/crates/perry-runtime/src/array/header.rs +++ b/crates/perry-runtime/src/array/header.rs @@ -29,13 +29,19 @@ thread_local! { /// this side table keyed by the array allocation address. Numeric array /// indices remain in element storage; canonical non-indices such as /// `"4294967295"` are stored here per ECMA-262. - static ARRAY_NAMED_PROPS: RefCell>> = - RefCell::new(HashMap::new()); + /// Address-keyed `PtrHashMap` (#6386): probed on every exec-array + /// decoration (regex match/exec) and every `ArraySpeciesCreate` + /// own-`constructor` check; SipHash dominated those probes. + static ARRAY_NAMED_PROPS: RefCell>> = + RefCell::new(crate::fast_hash::new_ptr_hash_map()); } #[derive(Clone)] struct ArrayNamedProperty { - name: String, + // `Cow` so the per-match exec-array keys (`index`/`input`/`groups`, + // #6386) borrow statically instead of allocating three `String`s per + // regex match; dynamically named expandos still own their key. + name: std::borrow::Cow<'static, str>, value: f64, } @@ -235,7 +241,7 @@ fn barrier_array_named_props(owner: usize, props: &mut [ArrayNamedProperty]) { } fn merge_array_named_props( - props: &mut HashMap>, + props: &mut crate::fast_hash::PtrHashMap>, owner: usize, owner_props: Vec, ) { @@ -319,7 +325,7 @@ pub(crate) unsafe fn array_named_property_set( prop.value = value; } else { props.push(ArrayNamedProperty { - name: name.to_string(), + name: std::borrow::Cow::Owned(name.to_string()), value, }); } @@ -327,6 +333,40 @@ pub(crate) unsafe fn array_named_property_set( }); } +/// Batched named-prop install for a FRESHLY built array (#6386): one +/// side-table probe for all entries and `&str` keys (no key `StringHeader` +/// allocations). Callers must guarantee the array was allocated in the same +/// runtime helper invocation — a fresh array has no accessor descriptors, no +/// property attributes, and no freeze/seal state, which is what makes +/// bypassing `js_array_set_string_key`'s guard ladder sound. Keys must not be +/// numeric index strings or `"length"` (those live in element storage / +/// the header, not this side table). +pub(crate) unsafe fn array_named_props_install_fresh( + arr: *mut ArrayHeader, + entries: &[(&'static str, f64)], +) { + let arr = clean_arr_ptr_mut(arr); + if arr.is_null() { + return; + } + let owner = arr as usize; + ARRAY_NAMED_PROPS.with(|m| { + let mut map = m.borrow_mut(); + let props = map.entry(owner).or_default(); + for (name, value) in entries { + if let Some(prop) = props.iter_mut().find(|prop| prop.name == *name) { + prop.value = *value; + } else { + props.push(ArrayNamedProperty { + name: std::borrow::Cow::Borrowed(*name), + value: *value, + }); + } + } + barrier_array_named_props(owner, props); + }); +} + pub(crate) unsafe fn array_named_property_get_by_name( arr: *const ArrayHeader, name: &str, @@ -393,7 +433,7 @@ pub(crate) unsafe fn array_named_property_names( .map(|attrs| attrs.enumerable()) .unwrap_or(true) }) - .map(|prop| prop.name.clone()) + .map(|prop| prop.name.to_string()) .collect() }) .unwrap_or_default() diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index 538999f994..9e0b3d8879 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -152,9 +152,10 @@ pub(crate) use self::flat_clone::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_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_ptr_as_proxy, canonicalize_array_numeric_store_value, clean_arr_ptr, clean_arr_ptr_mut, + array_named_property_names, array_named_property_set, array_named_props_install_fresh, + array_numeric_raw_f64_get, array_numeric_raw_f64_push_inbounds, + array_numeric_raw_f64_set_inbounds, array_object_flags, array_ptr_as_proxy, + canonicalize_array_numeric_store_value, clean_arr_ptr, clean_arr_ptr_mut, clear_array_numeric_layout, clear_array_numeric_layout_ptr, gc_element_slot_range, mark_array_layout_unknown, mark_array_raw_f64_holes_fresh, normalize_array_receiver, note_array_slot, note_array_slot_layout_only, rebuild_array_layout, rebuild_array_layout_exact, diff --git a/crates/perry-runtime/src/array/species.rs b/crates/perry-runtime/src/array/species.rs index f93e1c0c3c..0a1b48de09 100644 --- a/crates/perry-runtime/src/array/species.rs +++ b/crates/perry-runtime/src/array/species.rs @@ -95,6 +95,34 @@ unsafe fn resolve_species(original: f64) -> SpeciesChoice { if crate::value::js_is_truthy(crate::array::js_array_is_array(original)) == 0 { return SpeciesChoice::Default; } + // #6386 fast path: a plain dense `ArrayHeader` (not a proxy / subclass + // instance) whose own-`constructor` cannot exist — no `"constructor"` + // accessor was ever installed process-wide and the array's named-props + // side table has no `constructor` entry — resolves through the by-name + // walk to the intrinsic `Array`, i.e. `Default`. Behavior-identical to + // the walk: the array property walk does not model prototype-level + // `Array.prototype.constructor` mutation (verified against pre-change + // main), and both own-`constructor` stores land in the two tables + // consulted here. Skips the per-call key-string allocation and the + // full property walk. + { + let jv = JSValue::from_bits(original.to_bits()); + if jv.is_pointer() { + let raw = crate::value::js_nanbox_get_pointer(original) as usize; + if raw >= crate::gc::GC_HEADER_SIZE + 0x1000 { + let arr = raw as *const crate::array::ArrayHeader; + let hdr = + (raw as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; + if (*hdr).obj_type == crate::gc::GC_TYPE_ARRAY + && crate::array::array_ptr_as_proxy(arr).is_none() + && !crate::object::constructor_accessor_ever_installed() + && crate::array::array_named_property_get_by_name(arr, "constructor").is_none() + { + return SpeciesChoice::Default; + } + } + } + } // step 3: C = Get(O, "constructor"). step 5: if Type(C) is Object, // C = Get(C, @@species); a null species → undefined. let mut c = read_constructor(original); @@ -133,9 +161,29 @@ fn throw_not_constructor() -> ! { /// elements (via [[Set]] / CreateDataProperty for the custom case). May throw /// (poisoned constructor/@@species getter, or a non-constructor species). pub(crate) unsafe fn array_species_create(original: f64, length: usize) -> f64 { + array_species_create_with_capacity(original, length, 0) +} + +/// [`array_species_create`] with a result-capacity hint (#6386). Capacity is +/// unobservable, so when the default species applies the plain result can be +/// allocated at its final size up front — sparing the concat/slice-style +/// callers the grow-doubling allocations and copies of populating a +/// `MIN_ARRAY_CAPACITY` array element-by-element. The hint must come from +/// pure header peeks (no user code); a custom species constructor ignores it. +pub(crate) unsafe fn array_species_create_with_capacity( + original: f64, + length: usize, + capacity_hint: u32, +) -> f64 { match resolve_species(original) { SpeciesChoice::Default => { - let out = crate::array::js_array_alloc_with_length(length as u32); + let out = if capacity_hint > length as u32 { + let arr = crate::array::js_array_alloc(capacity_hint); + (*arr).length = length as u32; + arr + } else { + crate::array::js_array_alloc_with_length(length as u32) + }; f64::from_bits(JSValue::pointer(out as *const u8).bits()) } SpeciesChoice::Custom(c) => { diff --git a/crates/perry-runtime/src/buffer/dataview.rs b/crates/perry-runtime/src/buffer/dataview.rs index 2b92a63578..2ffc06d09c 100644 --- a/crates/perry-runtime/src/buffer/dataview.rs +++ b/crates/perry-runtime/src/buffer/dataview.rs @@ -18,18 +18,24 @@ use super::*; /// Numeric element kind for a DataView accessor. Encodes signedness, width and /// float-ness; endianness is a separate flag passed alongside. +/// +/// `repr(i32)` with explicit discriminants: the values are an ABI contract +/// with codegen's direct DataView lowering (#6386), which passes them as the +/// `kind_code` of `js_data_view_{get,set}_direct` — see +/// `data_view_kind_code` in `perry-codegen`'s DataView method lowering. #[derive(Clone, Copy, PartialEq, Eq)] +#[repr(i32)] pub enum DataViewKind { - Int8, - Uint8, - Int16, - Uint16, - Int32, - Uint32, - Float32, - Float64, - BigInt64, - BigUint64, + Int8 = 0, + Uint8 = 1, + Int16 = 2, + Uint16 = 3, + Int32 = 4, + Uint32 = 5, + Float32 = 6, + Float64 = 7, + BigInt64 = 8, + BigUint64 = 9, } impl DataViewKind { @@ -50,6 +56,41 @@ impl DataViewKind { matches!(self, DataViewKind::BigInt64 | DataViewKind::BigUint64) } + /// Inverse of the codegen `kind_code` ABI (see the enum doc): map the + /// discriminant back to a kind, rejecting out-of-range codes. + fn from_code(code: i32) -> Option { + Some(match code { + 0 => DataViewKind::Int8, + 1 => DataViewKind::Uint8, + 2 => DataViewKind::Int16, + 3 => DataViewKind::Uint16, + 4 => DataViewKind::Int32, + 5 => DataViewKind::Uint32, + 6 => DataViewKind::Float32, + 7 => DataViewKind::Float64, + 8 => DataViewKind::BigInt64, + 9 => DataViewKind::BigUint64, + _ => return None, + }) + } + + /// The `get*`/`set*` method-name suffix for this kind (fallback-dispatch + /// name reconstruction in the `*_direct` entry points). + fn method_suffix(self) -> &'static str { + match self { + DataViewKind::Int8 => "Int8", + DataViewKind::Uint8 => "Uint8", + DataViewKind::Int16 => "Int16", + DataViewKind::Uint16 => "Uint16", + DataViewKind::Int32 => "Int32", + DataViewKind::Uint32 => "Uint32", + DataViewKind::Float32 => "Float32", + DataViewKind::Float64 => "Float64", + DataViewKind::BigInt64 => "BigInt64", + DataViewKind::BigUint64 => "BigUint64", + } + } + /// Map a `get*`/`set*` method name (without the `get`/`set` prefix) to a /// kind. Returns `None` for an unrecognized element name. pub fn from_method_suffix(suffix: &str) -> Option { @@ -84,6 +125,12 @@ fn throw_dataview_oob() -> ! { /// `NaN`/`0` for those cases — so a Symbol didn't throw, `valueOf` never ran, and /// negative/Infinity offsets only surfaced (if at all) as a later bounds error. fn to_byte_offset(value: f64) -> i64 { + // Fast path (#6386): a non-NaN f64 is by NaN-boxing construction a + // genuine Number (every tag pattern is a NaN payload), so a valid + // integral index needs no coercion machinery at all. + if value >= 0.0 && value <= 9_007_199_254_740_991.0 && value.trunc() == value { + return value as i64; + } if crate::value::JSValue::from_bits(value.to_bits()).is_bigint() { crate::collection_iter::throw_type_error("Cannot convert a BigInt value to a number"); } @@ -103,6 +150,12 @@ fn to_byte_offset(value: f64) -> i64 { /// step order). A BigInt accessor takes the `to_bigint_raw_or_throw` path instead. #[inline] fn to_number(value: f64) -> f64 { + // A non-NaN f64 is by NaN-boxing construction already a Number (#6386); + // every non-Number value (and boxed int32) carries a NaN tag pattern and + // takes the full coercion. + if !value.is_nan() { + return value; + } crate::builtins::js_number_coerce(value) } @@ -295,6 +348,118 @@ pub fn js_data_view_set( f64::from_bits(crate::value::TAG_UNDEFINED) } +/// Shared receiver guard for the direct DataView accessor entries (#6386): +/// `Some(addr)` when `recv` is a NaN-boxed pointer to a registered DataView +/// with no own-prop shadow for `method_name` — i.e. when the specialized +/// helper may run without consulting the generic dispatch tower. +#[inline] +fn data_view_direct_receiver(recv: f64, method_name: &str) -> Option { + let bits = recv.to_bits(); + if bits >> 48 != 0x7FFD { + return None; + } + let addr = (bits & 0x0000_FFFF_FFFF_FFFF) as usize; + if !super::is_data_view(addr) { + return None; + } + // `dv.getFloat64 = fn` style shadows live in the buffer own-props table; + // the monotonic flag keeps this probe (a process-global mutex) off the + // hot path for programs that never store props on a buffer. + if super::buffer_own_props_possible() && super::buffer_get_own_prop(addr, method_name).is_some() + { + return None; + } + Some(addr) +} + +/// Cold fallback for the direct entries: a receiver whose static type said +/// `DataView` but which isn't one at runtime (reassigned variable, subclass +/// exotica, shadowed method) re-enters the generic dispatch tower under the +/// reconstructed method name, preserving its full semantics. +#[cold] +unsafe fn data_view_direct_fallback(recv: f64, method_name: &str, args: &[f64]) -> f64 { + crate::object::js_native_call_method( + recv, + method_name.as_ptr() as *const i8, + method_name.len(), + args.as_ptr(), + args.len(), + ) +} + +/// Direct codegen entry for `dv.get(byteOffset, littleEndian?)` on a +/// receiver statically typed `DataView` (#6386). Skips the generic +/// method-call tower (method-name interning, typed-feedback observation, +/// args-Vec + handle-scope setup, buffer/own-prop/registry dispatch ladder) +/// for the guarded common case. `little_value` is the RAW third argument +/// (TAG_UNDEFINED when absent — truthiness matches the generic path's +/// `args.len() >= 3 && truthy(args[2])`). `argc` is the source-level +/// argument count, forwarded so a fallback dispatch preserves the +/// callee-visible arity. +#[no_mangle] +pub extern "C" fn js_data_view_get_direct( + recv: f64, + offset: f64, + little_value: f64, + kind_code: i32, + argc: i32, +) -> f64 { + let Some(kind) = DataViewKind::from_code(kind_code) else { + return f64::from_bits(crate::value::TAG_UNDEFINED); + }; + let mut name_buf = [0u8; 16]; + let method_name = data_view_method_name(&mut name_buf, "get", kind); + if data_view_direct_receiver(recv, method_name).is_some() { + let little = crate::value::js_is_truthy(little_value) != 0; + return js_data_view_get(recv, offset, kind, little); + } + let args = [offset, little_value]; + unsafe { data_view_direct_fallback(recv, method_name, &args[..(argc.clamp(0, 2) as usize)]) } +} + +/// Direct codegen entry for `dv.set(byteOffset, value, littleEndian?)` +/// — see [`js_data_view_get_direct`]. +#[no_mangle] +pub extern "C" fn js_data_view_set_direct( + recv: f64, + offset: f64, + value: f64, + little_value: f64, + kind_code: i32, + argc: i32, +) -> f64 { + let Some(kind) = DataViewKind::from_code(kind_code) else { + return f64::from_bits(crate::value::TAG_UNDEFINED); + }; + let mut name_buf = [0u8; 16]; + let method_name = data_view_method_name(&mut name_buf, "set", kind); + if data_view_direct_receiver(recv, method_name).is_some() { + let little = crate::value::js_is_truthy(little_value) != 0; + return js_data_view_set(recv, offset, value, kind, little); + } + let args = [offset, value, little_value]; + unsafe { data_view_direct_fallback(recv, method_name, &args[..(argc.clamp(0, 3) as usize)]) } +} + +/// Assemble `get`/`set` in a stack buffer (no allocation on the +/// guard path, which needs the name for the own-prop shadow check). +#[inline] +fn data_view_method_name<'a>(buf: &'a mut [u8; 16], prefix: &str, kind: DataViewKind) -> &'a str { + let suffix = kind.method_suffix(); + buf[..3].copy_from_slice(prefix.as_bytes()); + buf[3..3 + suffix.len()].copy_from_slice(suffix.as_bytes()); + // Both halves are ASCII literals. + unsafe { std::str::from_utf8_unchecked(&buf[..3 + suffix.len()]) } +} + +// Called from generated code — keep the exports alive under release/LTO. +#[used] +static KEEP_JS_DATA_VIEW_GET_DIRECT: extern "C" fn(f64, f64, f64, i32, i32) -> f64 = + js_data_view_get_direct; +#[used] +static KEEP_JS_DATA_VIEW_SET_DIRECT: extern "C" fn(f64, f64, f64, f64, i32, i32) -> f64 = + js_data_view_set_direct; + /// ToIntN/ToUintN: truncate toward zero then reduce modulo 2^bits. NaN and the /// infinities map to 0 (per the abstract `ToNumber` → `ToIntegerOrInfinity` /// step used by DataView setters). diff --git a/crates/perry-runtime/src/buffer/mod.rs b/crates/perry-runtime/src/buffer/mod.rs index d4b8e1a62a..54aba3ad4c 100644 --- a/crates/perry-runtime/src/buffer/mod.rs +++ b/crates/perry-runtime/src/buffer/mod.rs @@ -60,8 +60,8 @@ pub(crate) use header::{test_data_view_registry_len, test_shared_array_buffer_re pub use detach::is_detached_buffer; pub(crate) use detach::{array_buffer_transfer, detach_array_buffer}; pub use own_props::{ - buffer_get_own_prop, buffer_has_own_prop, buffer_set_own_prop, clear_buffer_own_props, - scan_buffer_own_props_roots_mut, + buffer_get_own_prop, buffer_has_own_prop, buffer_own_props_possible, buffer_set_own_prop, + clear_buffer_own_props, scan_buffer_own_props_roots_mut, }; // ---- Re-exports: Buffer.from / alloc / concat (FFI) ---- diff --git a/crates/perry-runtime/src/buffer/own_props.rs b/crates/perry-runtime/src/buffer/own_props.rs index d43a07679c..c2d35c1c02 100644 --- a/crates/perry-runtime/src/buffer/own_props.rs +++ b/crates/perry-runtime/src/buffer/own_props.rs @@ -27,6 +27,7 @@ //! reachable, and the owner key is rewritten on evacuation. use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Mutex, OnceLock}; type BufferProps = HashMap>; @@ -36,11 +37,25 @@ fn buffer_props() -> &'static Mutex { PROPS.get_or_init(|| Mutex::new(HashMap::new())) } +/// Monotonic "some buffer own prop was ever stored" flag (#6386). Hot +/// accessor fast paths (DataView get*/set*) use it to skip the mutex + +/// double-HashMap shadow probe entirely in the overwhelmingly common program +/// that never assigns properties onto a buffer/typed-array/DataView. Set +/// (release) BEFORE the table insert, so a `false` (acquire) read guarantees +/// no insert has completed — the probe it skips could only have found nothing. +static BUFFER_OWN_PROPS_EVER: AtomicBool = AtomicBool::new(false); + +/// `false` while no buffer own prop has ever been stored process-wide. +pub fn buffer_own_props_possible() -> bool { + BUFFER_OWN_PROPS_EVER.load(Ordering::Acquire) +} + /// Store `buf. = value`. Only reached for a registered buffer address. pub fn buffer_set_own_prop(addr: usize, prop: &str, value: f64) { if addr == 0 { return; } + BUFFER_OWN_PROPS_EVER.store(true, Ordering::Release); if let Ok(mut props) = buffer_props().lock() { props .entry(addr) diff --git a/crates/perry-runtime/src/buffer/view.rs b/crates/perry-runtime/src/buffer/view.rs index f485ce37d5..3824a3eede 100644 --- a/crates/perry-runtime/src/buffer/view.rs +++ b/crates/perry-runtime/src/buffer/view.rs @@ -52,15 +52,18 @@ pub(crate) struct ViewInfo { } thread_local! { - /// `view_ptr → ViewInfo`. Lookups during writes are O(1). - static VIEW_REGISTRY: RefCell> = - RefCell::new(HashMap::with_capacity(64)); + /// `view_ptr → ViewInfo`. Lookups during writes are O(1). Address-keyed + /// `PtrHashMap` (#6386): both maps are probed on EVERY DataView/typed + /// view write via `propagate_written_range_from_receiver`, and SipHash + /// dominated those probes. + static VIEW_REGISTRY: RefCell> = + RefCell::new(crate::fast_hash::new_ptr_hash_map()); /// `backing_ptr → Vec`. Backing-side writes walk this /// list to mirror bytes into every aliased view. Vector entries /// are tombstoned (set to 0) on view drop rather than removed so /// hot-path iteration stays branch-light. - static BACKING_TO_VIEWS: RefCell>> = - RefCell::new(HashMap::with_capacity(64)); + static BACKING_TO_VIEWS: RefCell>> = + RefCell::new(crate::fast_hash::new_ptr_hash_map()); } #[inline] diff --git a/crates/perry-runtime/src/error.rs b/crates/perry-runtime/src/error.rs index eae9003794..0366565c4c 100644 --- a/crates/perry-runtime/src/error.rs +++ b/crates/perry-runtime/src/error.rs @@ -92,8 +92,14 @@ thread_local! { /// Only populated when the program was compiled with `--debug-symbols` /// (the flag that gates the codegen emission). `None` in the default /// build, so release perf and the `` fallback are unchanged. - static CURRENT_CALL_LOCATION: std::cell::RefCell> = - const { std::cell::RefCell::new(None) }; + /// Raw `(file_ptr, file_len, line)` of the pending call site. The + /// pointer is a codegen string-pool rodata global (process lifetime; + /// `js_set_call_location` is a generated-code-only callee), so storing + /// it raw and rendering lazily keeps the per-dispatch recording + /// allocation-free (#6386 — this runs before EVERY dynamic dispatch in + /// a `--debug-symbols` build). + static CURRENT_CALL_LOCATION: std::cell::Cell> = + const { std::cell::Cell::new(None) }; } /// #5247: record the source location of the call about to be dispatched. @@ -107,12 +113,10 @@ thread_local! { #[no_mangle] pub unsafe extern "C" fn js_set_call_location(file_ptr: *const u8, file_len: usize, line: u32) { if line == 0 || file_ptr.is_null() || file_len == 0 { - CURRENT_CALL_LOCATION.with(|c| *c.borrow_mut() = None); + CURRENT_CALL_LOCATION.with(|c| c.set(None)); return; } - let bytes = std::slice::from_raw_parts(file_ptr, file_len); - let file = String::from_utf8_lossy(bytes).into_owned(); - CURRENT_CALL_LOCATION.with(|c| *c.borrow_mut() = Some((file, line))); + CURRENT_CALL_LOCATION.with(|c| c.set(Some((file_ptr as usize, file_len, line)))); } // Generated-code-only callee: anchor against the auto-optimize LTO dead-strip @@ -124,8 +128,11 @@ static KEEP_JS_SET_CALL_LOCATION: unsafe extern "C" fn(*const u8, usize, u32) = /// #5247: render the current call-location frame, or `` when no /// location was recorded (default builds, or a synthesized/offset-less site). fn current_stack_frame() -> String { - CURRENT_CALL_LOCATION.with(|c| match &*c.borrow() { - Some((file, line)) => format!(" at {}:{}", file, line), + CURRENT_CALL_LOCATION.with(|c| match c.get() { + Some((file_ptr, file_len, line)) => { + let bytes = unsafe { std::slice::from_raw_parts(file_ptr as *const u8, file_len) }; + format!(" at {}:{}", String::from_utf8_lossy(bytes), line) + } None => " at ".to_string(), }) } diff --git a/crates/perry-runtime/src/object/class_registry/parent_static.rs b/crates/perry-runtime/src/object/class_registry/parent_static.rs index fde16068f5..11d33530ab 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -479,6 +479,7 @@ pub unsafe extern "C" fn js_register_class_computed_method( if sym_key == 0 { return; } + crate::symbol::note_symbol_key_installed(sym_key); { let mut guard = CLASS_SYMBOL_METHODS.write().unwrap(); if guard.is_none() { diff --git a/crates/perry-runtime/src/object/descriptor_state.rs b/crates/perry-runtime/src/object/descriptor_state.rs index 0b0bfb2bfa..e8728f3948 100644 --- a/crates/perry-runtime/src/object/descriptor_state.rs +++ b/crates/perry-runtime/src/object/descriptor_state.rs @@ -532,6 +532,25 @@ pub(crate) unsafe fn json_object_getter_value( Some(result) } +/// Monotonic (#6386): has an accessor descriptor keyed `"constructor"` ever +/// been installed on ANY object? While false, `ArraySpeciesCreate`'s +/// own-`constructor`-accessor probe on a plain array cannot hit, so the +/// species fast path skips the `(addr, String)` descriptor-table lookup (a +/// per-call `String` allocation + SipHash probe). Set (release) before the +/// insert, so a false (acquire) read can't race a completed install. +static CONSTRUCTOR_ACCESSOR_EVER: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +pub(crate) fn constructor_accessor_ever_installed() -> bool { + CONSTRUCTOR_ACCESSOR_EVER.load(Ordering::Acquire) +} + +fn note_accessor_descriptor_key(key: &str) { + if key == "constructor" { + CONSTRUCTOR_ACCESSOR_EVER.store(true, Ordering::Release); + } +} + /// Store an accessor descriptor for (obj, key). pub(crate) fn set_accessor_descriptor(obj: usize, key: String, acc: AccessorDescriptor) { super::prop_plan::prop_plan_epoch_bump(); @@ -539,6 +558,7 @@ pub(crate) fn set_accessor_descriptor(obj: usize, key: String, acc: AccessorDesc ACCESSORS_IN_USE.with(|c| c.set(true)); GLOBAL_DESCRIPTORS_IN_USE.store(true, Ordering::Relaxed); disable_class_field_inline_guard_for_target(obj); + note_accessor_descriptor_key(&key); ACCESSOR_DESCRIPTORS.with(|m| { m.borrow_mut().insert((obj, key), acc); }); @@ -574,6 +594,7 @@ pub(crate) fn set_builtin_accessor_descriptor( attrs: PropertyAttrs, ) { super::prop_plan::prop_plan_epoch_bump(); + note_accessor_descriptor_key(&key); ACCESSOR_DESCRIPTORS.with(|m| { m.borrow_mut().insert((obj, key.clone()), acc); }); diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 10ae2a4998..0802b5156a 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -158,14 +158,14 @@ pub use descriptor_state::PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED; pub(crate) use descriptor_state::{ accessor_descriptor_keys_for_obj, class_field_inline_guard_enabled, class_instance_set_may_intercept, clear_accessor_descriptor, clear_property_attrs, - descriptors_in_use, disable_class_field_inline_guard, get_accessor_descriptor, - get_property_attrs, json_object_getter_value, mark_all_keys, note_descriptor_target, - object_has_descriptors, object_proto_descriptors_in_use, object_proto_may_intercept_key, - plain_data_write_may_intercept, prune_dead_descriptor_owner_entries, - reflect_getter_closure_bits, set_accessor_descriptor, set_builtin_accessor_descriptor, - set_builtin_property_attrs, set_property_attrs, AccessorDescriptor, PropertyAttrs, - ACCESSORS_IN_USE, ACCESSOR_DESCRIPTORS, GLOBAL_DESCRIPTORS_IN_USE, PROPERTY_ATTRS_IN_USE, - PROPERTY_DESCRIPTORS, + constructor_accessor_ever_installed, descriptors_in_use, disable_class_field_inline_guard, + get_accessor_descriptor, get_property_attrs, json_object_getter_value, mark_all_keys, + note_descriptor_target, object_has_descriptors, object_proto_descriptors_in_use, + object_proto_may_intercept_key, plain_data_write_may_intercept, + prune_dead_descriptor_owner_entries, reflect_getter_closure_bits, set_accessor_descriptor, + set_builtin_accessor_descriptor, set_builtin_property_attrs, set_property_attrs, + AccessorDescriptor, PropertyAttrs, ACCESSORS_IN_USE, ACCESSOR_DESCRIPTORS, + GLOBAL_DESCRIPTORS_IN_USE, PROPERTY_ATTRS_IN_USE, PROPERTY_DESCRIPTORS, }; pub use this_binding::{ js_implicit_this_get, js_implicit_this_get_sloppy, js_implicit_this_set, js_new_target_get, diff --git a/crates/perry-runtime/src/regex/exec.rs b/crates/perry-runtime/src/regex/exec.rs index 0c47ce5c93..caf9873cbf 100644 --- a/crates/perry-runtime/src/regex/exec.rs +++ b/crates/perry-runtime/src/regex/exec.rs @@ -193,23 +193,28 @@ pub extern "C" fn js_regexp_exec( let str_ptr = js_string_from_str(m.as_str()); let nanboxed = js_nanbox_string(str_ptr as i64); let arr = arr_handle.get_raw_mut_ptr::(); - // GC_STORE_AUDIT(BARRIERED): regex exec capture slot uses the shared array slot-store helper. - crate::array::store_array_slot(arr, i, nanboxed.to_bits()); + // GC_STORE_AUDIT(INIT): fresh exec-array slot; layout is + // noted per store and the exact layout/barrier rebuild + // below the loop covers a mid-loop tenuring (#6386). + crate::array::note_array_slot_layout_only(arr, i, nanboxed.to_bits()); } else { let undefined = f64::from_bits(TAG_UNDEFINED); let arr = arr_handle.get_raw_mut_ptr::(); - // GC_STORE_AUDIT(BARRIERED): regex exec unmatched capture slot uses the shared array slot-store helper. - crate::array::store_array_slot(arr, i, undefined.to_bits()); + // GC_STORE_AUDIT(INIT): fresh exec-array slot; see above. + crate::array::note_array_slot_layout_only(arr, i, undefined.to_bits()); } } + // GC_STORE_AUDIT(BARRIERED): one exact rebuild replays any + // old-gen barriers for the whole capture prefix. + crate::array::rebuild_array_layout_exact( + arr_handle.get_raw_mut_ptr::(), + ); // Store .index in thread-local LAST_EXEC_INDEX.with(|idx| *idx.borrow_mut() = match_char_offset as f64); - set_exec_array_metadata( - arr_handle.get_raw_mut_ptr::(), - str_data, - match_char_offset as f64, - ); + // .index/.input attach via the combined fresh-array decoration + // below (#6386): one side-table probe for index/input/groups + // and a re-boxed (not copied) subject string. // Build groups object if named captures exist let group_names: Vec<(&str, Option)> = regex @@ -245,14 +250,18 @@ pub extern "C" fn js_regexp_exec( *g.borrow_mut() = groups_handle.get_raw_mut_ptr::() }); - set_exec_array_groups( + super::exec_array::set_exec_array_metadata_groups_fresh( arr_handle.get_raw_mut_ptr::(), + s, + match_char_offset as f64, groups_handle.get_raw_mut_ptr::(), ); } else { LAST_EXEC_GROUPS.with(|g| *g.borrow_mut() = ptr::null_mut()); - set_exec_array_groups( + super::exec_array::set_exec_array_metadata_groups_fresh( arr_handle.get_raw_mut_ptr::(), + s, + match_char_offset as f64, ptr::null_mut(), ); } diff --git a/crates/perry-runtime/src/regex/exec_array.rs b/crates/perry-runtime/src/regex/exec_array.rs index 8be43b3b7d..acc2a0c900 100644 --- a/crates/perry-runtime/src/regex/exec_array.rs +++ b/crates/perry-runtime/src/regex/exec_array.rs @@ -53,6 +53,50 @@ pub(super) fn set_exec_array_metadata_value(arr: *mut ArrayHeader, input_value: ); } +/// Combined `index`/`input`/`groups` decoration for a FRESHLY built +/// match-result array (#6386). Differences from calling +/// [`set_exec_array_metadata`] + [`set_exec_array_groups`]: +/// +/// * `input` re-boxes the already-heap-allocated subject `StringHeader` +/// instead of copying the whole subject per match (the string is demoted +/// to shared so a later in-place `s += x` on the source local can't edit +/// the stored property). +/// * all three properties land in the named-props side table with ONE probe +/// and no key-string allocations +/// (`crate::array::array_named_props_install_fresh`). +/// +/// Sound only because the array was allocated moments ago in the same +/// helper: it has no descriptors, no freeze/seal state, and no existing +/// named props, so the generic `js_array_set_string_key` ladder is +/// observationally skipped. Performs no GC allocation, so no rooting needed. +pub(super) fn set_exec_array_metadata_groups_fresh( + arr: *mut ArrayHeader, + input: *const crate::string::StringHeader, + index: f64, + groups_obj: *mut ObjectHeader, +) { + if arr.is_null() { + return; + } + let input_value = js_nanbox_string(input as i64); + crate::string::js_string_addref_if_heap_string(input_value); + let groups_value = if groups_obj.is_null() { + f64::from_bits(0x7FFC_0000_0000_0001) // TAG_UNDEFINED + } else { + crate::value::js_nanbox_pointer(groups_obj as i64) + }; + unsafe { + crate::array::array_named_props_install_fresh( + arr, + &[ + ("index", index), + ("input", input_value), + ("groups", groups_value), + ], + ); + } +} + /// Attach the `groups` own property to a regex match-result array. /// /// Mirrors `set_exec_array_metadata` for `index`/`input`: the result of diff --git a/crates/perry-runtime/src/regex/match_string.rs b/crates/perry-runtime/src/regex/match_string.rs index d58fab7e77..14e6594866 100644 --- a/crates/perry-runtime/src/regex/match_string.rs +++ b/crates/perry-runtime/src/regex/match_string.rs @@ -225,29 +225,34 @@ pub extern "C" fn js_string_match( let str_ptr = js_string_from_str(m.as_str()); let nanboxed = js_nanbox_string(str_ptr as i64); let arr = arr_handle.get_raw_mut_ptr::(); - // GC_STORE_AUDIT(BARRIERED): regex capture array slot uses the shared array slot-store helper. - crate::array::store_array_slot(arr, i, nanboxed.to_bits()); + // GC_STORE_AUDIT(INIT): fresh match-array slot; layout is + // noted per store and the exact layout/barrier rebuild + // below the loop covers a mid-loop tenuring (#6386). + crate::array::note_array_slot_layout_only(arr, i, nanboxed.to_bits()); } else { // Undefined capture group - store as undefined (TAG_UNDEFINED = 0x7FFC_0000_0000_0001) let undefined = f64::from_bits(0x7FFC_0000_0000_0001); let arr = arr_handle.get_raw_mut_ptr::(); - // GC_STORE_AUDIT(BARRIERED): regex unmatched capture slot uses the shared array slot-store helper. - crate::array::store_array_slot(arr, i, undefined.to_bits()); + // GC_STORE_AUDIT(INIT): fresh match-array slot; see above. + crate::array::note_array_slot_layout_only(arr, i, undefined.to_bits()); } } + // GC_STORE_AUDIT(BARRIERED): one exact rebuild replays any + // old-gen barriers for the whole capture prefix. + crate::array::rebuild_array_layout_exact( + arr_handle.get_raw_mut_ptr::(), + ); // Attach .index / .input as real own properties (mirrors // js_regexp_exec) so they survive aliasing and a later match // on another regex, instead of a most-recent-match thread-local. + // Deferred into the combined fresh-array decoration below + // (#6386) so index/input/groups cost one side-table probe + // and the subject string is re-boxed, not copied. let match_char_offset = caps .get(0) .map(|m| super::utf16::byte_index_to_utf16_index(str_data, m.start())) .unwrap_or(0); - set_exec_array_metadata( - arr_handle.get_raw_mut_ptr::(), - str_data, - match_char_offset as f64, - ); // Build groups object for named captures (same shape as // `regex.exec(str)` does in `js_regexp_exec`). Stored in @@ -296,14 +301,18 @@ pub extern "C" fn js_string_match( *g.borrow_mut() = groups_handle.get_raw_mut_ptr::() }); - set_exec_array_groups( + super::exec_array::set_exec_array_metadata_groups_fresh( arr_handle.get_raw_mut_ptr::(), + s, + match_char_offset as f64, groups_handle.get_raw_mut_ptr::(), ); } else { LAST_EXEC_GROUPS.with(|g| *g.borrow_mut() = ptr::null_mut()); - set_exec_array_groups( + super::exec_array::set_exec_array_metadata_groups_fresh( arr_handle.get_raw_mut_ptr::(), + s, + match_char_offset as f64, ptr::null_mut(), ); } diff --git a/crates/perry-runtime/src/symbol.rs b/crates/perry-runtime/src/symbol.rs index e6a122f656..fe2927a358 100644 --- a/crates/perry-runtime/src/symbol.rs +++ b/crates/perry-runtime/src/symbol.rs @@ -449,6 +449,48 @@ pub(crate) unsafe fn sym_key_from_f64(sym_f64: f64) -> usize { ptr as usize } +/// Monotonic gate (#6386): has a `Symbol.isConcatSpreadable`-keyed property +/// EVER been installed anywhere (instance symbol store, symbol accessor, +/// symbol defineProperty attrs, class static symbol)? While `false`, the +/// spreadable read `Array.prototype.concat` performs per argument is +/// guaranteed to find undefined for any non-proxy value — and to be +/// side-effect free — so the whole lookup ladder can be skipped. +static CONCAT_SPREADABLE_EVER: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +pub(crate) fn concat_spreadable_symbol_ever_set() -> bool { + CONCAT_SPREADABLE_EVER.load(std::sync::atomic::Ordering::Acquire) +} + +/// Note a symbol-keyed property install. Flips the gate when the key is the +/// well-known `isConcatSpreadable`. Must be called BEFORE the table insert in +/// every install funnel, so a `false` (acquire) read can never race a +/// completed insert. +pub(crate) fn note_symbol_key_installed(sym_key: usize) { + if sym_key == 0 || CONCAT_SPREADABLE_EVER.load(std::sync::atomic::Ordering::Relaxed) { + return; + } + // Non-allocating peek: a stored key can only BE the well-known + // `isConcatSpreadable` if that symbol was already materialized (every + // user route to it goes through `well_known_symbol`). Never create it + // here — this runs on every symbol install and must not perturb + // allocation accounting. + let wk = well_known_symbol_if_cached("isConcatSpreadable"); + if !wk.is_null() && sym_key == wk as usize { + CONCAT_SPREADABLE_EVER.store(true, std::sync::atomic::Ordering::Release); + } +} + +/// The cached well-known symbol pointer if `short_name` was ever +/// materialized, else null. Unlike [`well_known_symbol`], never allocates. +pub(crate) fn well_known_symbol_if_cached(short_name: &str) -> *mut SymbolHeader { + let guard = WELL_KNOWN_SYMBOLS.lock().unwrap(); + guard + .as_ref() + .and_then(|m| m.get(short_name).copied()) + .unwrap_or(0) as *mut SymbolHeader +} + pub(crate) fn publish_symbol_side_table_root_edges(sym_key: usize, value_bits: u64) { crate::gc::runtime_write_barrier_root_raw_ptr(sym_key as *const SymbolHeader); crate::gc::runtime_write_barrier_root_nanbox(value_bits); @@ -459,6 +501,7 @@ pub(crate) fn store_object_symbol_property_root( sym_key: usize, value_bits: u64, ) -> bool { + note_symbol_key_installed(sym_key); { let mut guard = crate::gc::lock_gc_root_registry(&SYMBOL_PROPERTIES); if guard.is_none() { @@ -481,6 +524,7 @@ pub(crate) fn store_object_symbol_property_root( } pub(crate) fn store_class_static_symbol_root(class_id: u32, sym_key: usize, value_bits: u64) { + note_symbol_key_installed(sym_key); { let mut guard = crate::gc::lock_gc_root_registry(&CLASS_STATIC_SYMBOLS); if guard.is_none() { diff --git a/crates/perry-runtime/src/symbol/accessors.rs b/crates/perry-runtime/src/symbol/accessors.rs index bfc6f54f31..839c202d57 100644 --- a/crates/perry-runtime/src/symbol/accessors.rs +++ b/crates/perry-runtime/src/symbol/accessors.rs @@ -34,6 +34,7 @@ pub(crate) unsafe fn set_symbol_accessor_property( if obj_key == 0 || sym_key == 0 { return; } + crate::symbol::note_symbol_key_installed(sym_key); { let mut props = crate::gc::lock_gc_root_registry(&SYMBOL_PROPERTIES); if let Some(map) = props.as_mut() { diff --git a/crates/perry-runtime/src/symbol/properties.rs b/crates/perry-runtime/src/symbol/properties.rs index d2308108df..ca2ca0cc08 100644 --- a/crates/perry-runtime/src/symbol/properties.rs +++ b/crates/perry-runtime/src/symbol/properties.rs @@ -62,6 +62,7 @@ pub(crate) fn set_symbol_property_attrs( if owner == 0 || sym_key == 0 { return; } + super::note_symbol_key_installed(sym_key); let mut guard = crate::gc::lock_gc_root_registry(&SYMBOL_PROPERTY_ATTRS); if guard.is_none() { *guard = Some(HashMap::new()); @@ -266,6 +267,7 @@ unsafe fn set_symbol_property(obj_f64: f64, sym_f64: f64, value_f64: f64) -> f64 if obj_key == 0 || sym_key == 0 { return value_f64; } + super::note_symbol_key_installed(sym_key); // #5437 (Next.js): a native HANDLE (small-id NaN-boxed POINTER, e.g. the // node:http IncomingMessage) carries per-request metadata in the symbol // side table keyed by its handle id. Node shares one metadata object by diff --git a/test-files/test_gap_6386_dataview_concat_regex_fastpaths.ts b/test-files/test_gap_6386_dataview_concat_regex_fastpaths.ts new file mode 100644 index 0000000000..d65aaaa9d8 --- /dev/null +++ b/test-files/test_gap_6386_dataview_concat_regex_fastpaths.ts @@ -0,0 +1,130 @@ +// #6386: guarded fast paths for DataView accessors, Array.prototype.concat, +// and regex match materialization. Each block exercises the fast path AND the +// condition that must kick execution back to the generic/spec path — +// including mid-run flips of the monotonic gates (isConcatSpreadable ever +// set, constructor accessor ever installed, buffer own props ever stored), +// which are load-bearing: the fast path must keep honoring exotica installed +// AFTER it has already run hot. + +// ---- DataView direct accessors -------------------------------------------- +{ + const b = new ArrayBuffer(64); + const v = new DataView(b); + // Hot loop first: the direct entries run repeatedly before any exotica. + let acc = 0; + for (let i = 0; i < 1000; i++) { + v.setFloat64(8, i * 1.5, true); + acc += v.getFloat64(8, true); + } + console.log("dv hot:", acc); + // Endianness default (big) vs explicit little. + v.setFloat64(0, 1.5); + console.log("dv be/le:", v.getFloat64(0), v.getFloat64(0, true)); + v.setInt16(2, -2, true); + console.log("dv i16/u16:", v.getInt16(2, true), v.getUint16(2, true)); + // Wrap semantics on setters. + v.setUint16(4, 70000, true); + console.log("dv wrap:", v.getUint16(4, true)); + v.setInt8(6, -1); + console.log("dv u8 of -1:", v.getUint8(6)); + // Offset coercions the fast path must not break: fractional-but-integral + // doubles, booleans, numeric strings via objects. + v.setInt32(8.0, 42, true); + console.log("dv int off:", v.getInt32(8, true)); + // Out-of-bounds RangeError still throws. + try { + v.getFloat64(60, true); + console.log("dv oob: NO THROW"); + } catch (e) { + console.log("dv oob:", (e as Error).constructor.name); + } + // Static type violated at runtime: the same call site must fall back to + // generic dispatch when the variable holds a plain object. + let w: any = new DataView(b); + w.setUint8(0, 7); + console.log("dv typed:", w.getUint8(0)); + w = { getUint8: (o: number) => 123 + o, setUint8: (o: number, x: number) => 0 }; + console.log("dv reassigned:", w.getUint8(1)); + // BigInt accessors ride the same direct path. + const v2 = new DataView(new ArrayBuffer(16)); + v2.setBigInt64(0, -2n, true); + console.log("dv bigint:", v2.getBigInt64(0, true), v2.getBigUint64(0, true)); +} + +// ---- Array.prototype.concat ----------------------------------------------- +{ + // Hot dense loop first (all-dense bulk path). + const x: number[] = [], y: number[] = []; + for (let i = 0; i < 100; i++) { x.push(i); y.push(i + 100); } + let n = 0; + for (let r = 0; r < 500; r++) n += ([] as number[]).concat(x, y).length; + console.log("concat hot:", n); + // Mixed values: primitives, strings, nested arrays stay nested one level. + const mixed = [1].concat(2, "three", [4, [5]], true as any, null as any); + console.log("concat mixed:", JSON.stringify(mixed)); + // Holes are preserved (slow path), inherited reads NOT collapsed. + const holey = [1, , 3]; + const hres = [0].concat(holey); + console.log("concat holes:", hres.length, 1 in hres, 2 in hres, hres[3]); + // Strings copied by reference must not alias later source mutation. + let s = "ab"; + const strres = ([] as string[]).concat([s]); + s += "cd"; + console.log("concat str demote:", strres[0], s); + // Mid-run flip: installing @@isConcatSpreadable ANYWHERE after hot concats + // ran must be honored (the monotonic gate opens the spec path). Own symbol + // props on array instances are a separate pre-existing gap (unchanged by + // #6386): concat doesn't see `arr[Symbol.isConcatSpreadable]`, so the flip + // is exercised through object receivers, which are modeled. + const fake: any = { length: 2, 0: "a", 1: "b", [Symbol.isConcatSpreadable]: true }; + console.log("concat spread obj:", JSON.stringify([1].concat(fake))); + // The flag is now flipped process-wide; dense array concat must still be + // correct (spec path or fast path both produce this). + console.log("concat post-flip:", JSON.stringify([1].concat([9, 8]))); + // Mid-run flip: an OWN constructor on one array redirects species for that + // array only; plain arrays keep the fast default. + function Custom(this: any, len: number) { this.len = len; } + (Custom as any)[Symbol.species] = Custom; + const withCtor: any = [1, 2]; + withCtor.constructor = Custom; + const custom = withCtor.concat([3]); + console.log("concat species:", Array.isArray(custom), custom instanceof (Custom as any)); + console.log("concat plain still fast:", JSON.stringify([1].concat([2, 3]))); +} + +// ---- Regex match / exec materialization ----------------------------------- +{ + const s = "2026-07-13 key=42 val=99"; + const re = /(\d{4})-(\d{2})-(\d{2}) key=(\d+)/; + // Hot loop. + let a = 0; + for (let i = 0; i < 2000; i++) { + const m = s.match(re); + if (m) a += m[1].length + m[4].length; + } + console.log("rm hot:", a); + const m = s.match(re)!; + console.log("rm caps:", JSON.stringify(Array.from(m))); + console.log("rm index/input:", m.index, m.input === s, m.groups); + // Named groups build a real groups object. + const nm = "x=7".match(/(?\w)=(?\d)/)!; + console.log("rm named:", nm.groups!.key, nm.groups!.val, nm.index); + // Unmatched optional group is undefined in the array. + const om = "ab".match(/a(z)?(b)/)!; + console.log("rm optional:", om[1], om[2], om.length); + // exec: same decoration + lastIndex behavior for /g. + const gre = /k(\d)/g; + const subject = "k1 k2"; + const e1 = gre.exec(subject)!; + const e2 = gre.exec(subject)!; + console.log("rm exec:", e1[1], e1.index, e2[1], e2.index, gre.lastIndex); + // Subject re-boxed as .input must not alias later mutation of the local. + let subj = "q=5"; + const mm = subj.match(/q=(\d)/)!; + subj += "!"; + console.log("rm input demote:", mm.input, subj); + // Match results survive an interleaved match on another regex. + const mA = "aa".match(/(a)(a)/)!; + const mB = "bb".match(/(b)/)!; + console.log("rm interleave:", mA.index, mA[2], mB.index, mB[1]); +} From bde8299139b1b4853cd98051ed008b12b07f2802 Mon Sep 17 00:00:00 2001 From: Ralph Date: Fri, 17 Jul 2026 13:38:19 -0700 Subject: [PATCH 2/2] fix: address CodeRabbit review on #6529 fast paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - species.rs: proxy check now precedes the GcHeader deref in the species fast path — a masked proxy id above 0x1008 would have been dereferenced as header memory (CodeRabbit: critical) - the all-dense concat bulk path is gated on the species resolution actually taking the DEFAULT branch (array_species_create_with_capacity now returns that flag) instead of the result's GC type — a custom @@species constructor can return a plain-typed array that is frozen, sealed, or pre-populated, which must keep the staging flow - string shared-demotes moved after the committed copy in both bulk paths; pass 2 of the all-dense path consumes pass 1's validated sources (fixed 8-slot buffer) so it has no bail-out point that could let the fallback re-apply a demote - js_register_class_computed_accessor flips the isConcatSpreadable gate like the computed-method path already did - gap test: own-prop shadowing of a DataView accessor after the direct path ran hot (mid-run buffer own-prop gate flip) Co-Authored-By: Claude Fable 5 --- crates/perry-runtime/src/array/from_concat.rs | 83 +++++++++++-------- crates/perry-runtime/src/array/species.rs | 29 +++++-- .../object/class_registry/parent_static.rs | 1 + ...ap_6386_dataview_concat_regex_fastpaths.ts | 8 ++ 4 files changed, 81 insertions(+), 40 deletions(-) diff --git a/crates/perry-runtime/src/array/from_concat.rs b/crates/perry-runtime/src/array/from_concat.rs index 846386110d..210e72df53 100644 --- a/crates/perry-runtime/src/array/from_concat.rs +++ b/crates/perry-runtime/src/array/from_concat.rs @@ -218,10 +218,11 @@ pub extern "C" fn js_array_concat_variadic( // (unobservable), so it runs before the observable species resolution // without reordering anything the spec sequences. let cap_hint = unsafe { concat_capacity_hint(recv, args_ptr, count) }; - let (result_box, result_is_plain) = unsafe { - let b = crate::array::species::array_species_create_with_capacity(recv_value, 0, cap_hint); - (b, crate::array::species::species_result_is_plain_array(b)) + let (result_box, species_was_default) = unsafe { + crate::array::species::array_species_create_with_capacity(recv_value, 0, cap_hint) }; + let result_is_plain = species_was_default + || unsafe { crate::array::species::species_result_is_plain_array(result_box) }; let result = if result_is_plain { crate::value::js_nanbox_get_pointer(result_box) as *mut ArrayHeader } else { @@ -229,13 +230,15 @@ pub extern "C" fn js_array_concat_variadic( // array first, then CreateDataProperty them onto the container below. js_array_alloc(cap_hint) }; - // #6386 all-dense bulk path: when the spreadable gate is closed and every - // source is a plain dense hole-free array (or a non-pointer single - // value), fill the pre-sized plain result with one copy pass and ONE - // layout/barrier rebuild — no per-source rebuild, no spreadable reads, - // no growth. Falls through (result still empty) when anything exotic - // shows up. - if result_is_plain && !crate::symbol::concat_spreadable_symbol_ever_set() { + // #6386 all-dense bulk path: when the DEFAULT species ran (so the result + // is guaranteed fresh, empty, and unfrozen — a custom `@@species` ctor + // can return a plain-typed array that is none of those), the spreadable + // gate is closed, and every source is a plain dense hole-free array (or + // a non-pointer single value), fill the pre-sized result with one copy + // pass and ONE layout/barrier rebuild — no per-source rebuild, no + // spreadable reads, no growth. Falls through (result still empty) when + // anything exotic shows up. + if species_was_default && !crate::symbol::concat_spreadable_symbol_ever_set() { if let Some(out) = unsafe { try_concat_all_dense(result, recv, args_ptr, count) } { return out; } @@ -855,20 +858,32 @@ unsafe fn try_concat_all_dense( args_ptr: *const f64, count: i32, ) -> Option<*mut ArrayHeader> { + // Small fixed classification buffer: pass 1's validated (ptr, len) pairs + // feed pass 2 directly, so pass 2 has NO bail-out point — no side effect + // (string shared-demote) can be applied and then re-applied by the + // fallback path. Wider argument lists take the per-source flow. + const MAX_DENSE_ARGS: usize = 8; let count = count.max(0) as usize; + if count > MAX_DENSE_ARGS { + return None; + } let args: &[f64] = if args_ptr.is_null() || count == 0 { &[] } else { std::slice::from_raw_parts(args_ptr, count) }; - // Pass 1: validate every source and total the lengths. + // Pass 1: validate every source and total the lengths. `None` in a slot + // marks a single-value (non-pointer) argument occupying one result slot. let (recv_src, recv_len) = dense_concat_array_source(recv)?; + let mut arg_sources: [Option<(*const ArrayHeader, u32)>; MAX_DENSE_ARGS] = + [None; MAX_DENSE_ARGS]; let mut total: u64 = recv_len as u64; - for &arg in args { + for (i, &arg) in args.iter().enumerate() { let bits = arg.to_bits(); if bits >> 48 == 0x7FFD { - let (_, len) = + let (src, len) = dense_concat_array_source((bits & 0x0000_FFFF_FFFF_FFFF) as *const ArrayHeader)?; + arg_sources[i] = Some((src, len)); total += len as u64; } else { // Non-pointer value (number / string-by-tag / bool / undefined / @@ -883,9 +898,9 @@ unsafe fn try_concat_all_dense( if total > (*result).capacity { return None; } - // Pass 2: copy. Nothing below allocates, so no GC can move a source or - // the result mid-copy, which is what makes the single deferred rebuild - // sound. + // Pass 2: copy. Nothing below allocates or bails, so no GC can move a + // source or the result mid-copy and no shared-demote runs twice — which + // is what makes the single deferred rebuild sound. let dst = (result as *mut u8).add(std::mem::size_of::()) as *mut f64; let mut off: usize = 0; let mut copy_array = |src: *const ArrayHeader, len: u32, off: &mut usize| { @@ -908,16 +923,11 @@ unsafe fn try_concat_all_dense( *off += len as usize; }; copy_array(recv_src, recv_len, &mut off); - for &arg in args { - let bits = arg.to_bits(); - if bits >> 48 == 0x7FFD { - // Re-validated cheaply: pass 1 proved this resolves to a dense - // array; nothing has run since that could change it. - let (src, len) = - dense_concat_array_source((bits & 0x0000_FFFF_FFFF_FFFF) as *const ArrayHeader)?; + for (i, &arg) in args.iter().enumerate() { + if let Some((src, len)) = arg_sources[i] { copy_array(src, len, &mut off); } else { - if bits >> 48 == 0x7FFF { + if arg.to_bits() >> 48 == 0x7FFF { crate::string::js_string_addref_if_heap_string(arg); } std::ptr::write(dst.add(off), arg); @@ -983,19 +993,16 @@ unsafe fn try_append_spread_array_dense( if crate::array::array_is_frozen(result) || crate::array::array_is_sealed_or_no_extend(result) { return None; } - // Single pass: holes need the spec `HasProperty`/`Get` reads (inherited - // elements, side-table entries) — punt those to the slow path. Heap - // strings get the same shared-demote `js_array_push_f64` performs, so a - // later mutation of the source local can't edit the stored element. + // Validation pass, no side effects: holes need the spec + // `HasProperty`/`Get` reads (inherited elements, side-table entries) — + // punt those to the slow path. String addrefs happen only after the copy + // has committed below, so a mid-scan bail can't leave the fallback path + // double-retaining an already-addref'd string. let src_elems = (src as *const u8).add(std::mem::size_of::()) as *const f64; for i in 0..src_len as usize { - let v = *src_elems.add(i); - if v.to_bits() == crate::value::TAG_HOLE { + if (*src_elems.add(i)).to_bits() == crate::value::TAG_HOLE { return None; } - if v.to_bits() >> 48 == 0x7FFF { - crate::string::js_string_addref_if_heap_string(v); - } } let dest_len = (*result).length; let new_len = dest_len.checked_add(src_len)?; @@ -1024,6 +1031,16 @@ unsafe fn try_append_spread_array_dense( dst_elems.add(dest_len as usize), src_len as usize, ); + // The copy has committed — apply the same shared-demote + // `js_array_push_f64` performs per element, so a later in-place mutation + // of a source string local can't edit the stored element. Runs after + // every bail-out point so a fallback re-append can't double-retain. + for i in dest_len as usize..new_len as usize { + let v = *dst_elems.add(i); + if v.to_bits() >> 48 == 0x7FFF { + crate::string::js_string_addref_if_heap_string(v); + } + } (*result).length = new_len; crate::array::rebuild_array_layout_exact(result); Some(result) diff --git a/crates/perry-runtime/src/array/species.rs b/crates/perry-runtime/src/array/species.rs index 0a1b48de09..938b4bda31 100644 --- a/crates/perry-runtime/src/array/species.rs +++ b/crates/perry-runtime/src/array/species.rs @@ -109,12 +109,15 @@ unsafe fn resolve_species(original: f64) -> SpeciesChoice { let jv = JSValue::from_bits(original.to_bits()); if jv.is_pointer() { let raw = crate::value::js_nanbox_get_pointer(original) as usize; - if raw >= crate::gc::GC_HEADER_SIZE + 0x1000 { - let arr = raw as *const crate::array::ArrayHeader; + let arr = raw as *const crate::array::ArrayHeader; + // Proxy check FIRST: a masked proxy id is not a heap pointer, so + // the GcHeader deref below would read unmapped memory for one. + if crate::array::array_ptr_as_proxy(arr).is_none() + && raw >= crate::gc::GC_HEADER_SIZE + 0x1000 + { let hdr = (raw as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; if (*hdr).obj_type == crate::gc::GC_TYPE_ARRAY - && crate::array::array_ptr_as_proxy(arr).is_none() && !crate::object::constructor_accessor_ever_installed() && crate::array::array_named_property_get_by_name(arr, "constructor").is_none() { @@ -161,7 +164,7 @@ fn throw_not_constructor() -> ! { /// elements (via [[Set]] / CreateDataProperty for the custom case). May throw /// (poisoned constructor/@@species getter, or a non-constructor species). pub(crate) unsafe fn array_species_create(original: f64, length: usize) -> f64 { - array_species_create_with_capacity(original, length, 0) + array_species_create_with_capacity(original, length, 0).0 } /// [`array_species_create`] with a result-capacity hint (#6386). Capacity is @@ -170,11 +173,17 @@ pub(crate) unsafe fn array_species_create(original: f64, length: usize) -> f64 { /// callers the grow-doubling allocations and copies of populating a /// `MIN_ARRAY_CAPACITY` array element-by-element. The hint must come from /// pure header peeks (no user code); a custom species constructor ignores it. +/// +/// The second return is `true` only when the DEFAULT species branch ran — +/// i.e. the result is a freshly allocated, empty, unfrozen plain array. A +/// custom `@@species` constructor can RETURN a plain-typed array too (frozen, +/// sealed, or pre-populated), so callers wanting raw-write access must gate +/// on this flag, not on the result's GC type. pub(crate) unsafe fn array_species_create_with_capacity( original: f64, length: usize, capacity_hint: u32, -) -> f64 { +) -> (f64, bool) { match resolve_species(original) { SpeciesChoice::Default => { let out = if capacity_hint > length as u32 { @@ -184,11 +193,17 @@ pub(crate) unsafe fn array_species_create_with_capacity( } else { crate::array::js_array_alloc_with_length(length as u32) }; - f64::from_bits(JSValue::pointer(out as *const u8).bits()) + ( + f64::from_bits(JSValue::pointer(out as *const u8).bits()), + true, + ) } SpeciesChoice::Custom(c) => { let args = [length as f64]; - crate::object::js_new_function_construct(c, args.as_ptr(), args.len()) + ( + crate::object::js_new_function_construct(c, args.as_ptr(), args.len()), + false, + ) } } } diff --git a/crates/perry-runtime/src/object/class_registry/parent_static.rs b/crates/perry-runtime/src/object/class_registry/parent_static.rs index 11d33530ab..1ec58bb832 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -616,6 +616,7 @@ pub unsafe extern "C" fn js_register_class_computed_accessor( if sym_key == 0 { return; } + crate::symbol::note_symbol_key_installed(sym_key); let mut guard = CLASS_SYMBOL_ACCESSORS.write().unwrap(); if guard.is_none() { *guard = Some(HashMap::new()); diff --git a/test-files/test_gap_6386_dataview_concat_regex_fastpaths.ts b/test-files/test_gap_6386_dataview_concat_regex_fastpaths.ts index d65aaaa9d8..64e27fd1a1 100644 --- a/test-files/test_gap_6386_dataview_concat_regex_fastpaths.ts +++ b/test-files/test_gap_6386_dataview_concat_regex_fastpaths.ts @@ -49,6 +49,14 @@ const v2 = new DataView(new ArrayBuffer(16)); v2.setBigInt64(0, -2n, true); console.log("dv bigint:", v2.getBigInt64(0, true), v2.getBigUint64(0, true)); + // Mid-run flip of the own-prop gate: an own method assigned onto a + // DataView AFTER the direct path ran hot must shadow the prototype + // accessor at the same call site. + const v3: any = new DataView(new ArrayBuffer(8)); + v3.setUint8(0, 5); + console.log("dv pre-shadow:", v3.getUint8(0)); + v3.getUint8 = (o: number) => 42 + o; + console.log("dv shadowed:", v3.getUint8(1)); } // ---- Array.prototype.concat -----------------------------------------------