From da7e8d54667237f829f0ca95142854bc424d270b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 05:05:20 +0200 Subject: [PATCH 1/2] perf(runtime): resolve Array.prototype.map's result header past 64 elements Array.prototype.map's plain-result fill used the once-resolved-header fast path (fill_resolved_array_slot) only for a source of at most 64 elements; longer sources fell back to note_array_slot, which re-classifies the result's ownership/forwarding through clean_arr_ptr on every element (array_numeric_layout) and unconditionally pays layout_note_slot. The result pointer is re-derived from its own GC root immediately before either helper runs, with no intervening allocation or safepoint, so fill_resolved_array_slot's contract holds regardless of length -- the 64-element split was scope, not a correctness boundary. Also drops a redundant raw ptr::write of the mapped value that ran right before both branches; both helpers perform their own (possibly canonicalized) store of the same slot, so it was always immediately overwritten. a.map(x => x + v) over a 16-vs-80-element number[]: 450.8 -> 206.1 instructions per element (-54.3%), measured as the marginal per-call cost difference of two probes differing only in element count (control (loop80-loop16)/64 ~0 in both arms). --- .../array-map-resolved-fill-past-64.md | 30 ++++ .../perry-runtime/src/array/iter_methods.rs | 25 ++- .../test_gap_array_map_resolved_fill_scale.ts | 146 ++++++++++++++++++ 3 files changed, 186 insertions(+), 15 deletions(-) create mode 100644 changelog.d/array-map-resolved-fill-past-64.md create mode 100644 test-files/test_gap_array_map_resolved_fill_scale.ts diff --git a/changelog.d/array-map-resolved-fill-past-64.md b/changelog.d/array-map-resolved-fill-past-64.md new file mode 100644 index 0000000000..bccbc47c9d --- /dev/null +++ b/changelog.d/array-map-resolved-fill-past-64.md @@ -0,0 +1,30 @@ +`Array.prototype.map` filling a plain result array only took the once-resolved +header fast path (`fill_resolved_array_slot`, from the earlier map fill +change) for a source of at most 64 elements; a longer source fell back to +`note_array_slot`, which re-classifies the result's ownership/forwarding +through `clean_arr_ptr` (`array_numeric_layout`) and unconditionally pays +`layout_note_slot`, on every element. `result` is re-derived from the result's +own GC root immediately after the callback returns and before either helper +runs, so the "no intervening allocation or safepoint" contract +`fill_resolved_array_slot` needs holds regardless of length — the 64-element +split was scope, not a correctness boundary. It now applies unconditionally. + +Also dropped a redundant raw `ptr::write` of the mapped value that ran +immediately before both branches — both `fill_resolved_array_slot` and +`note_array_slot` perform their own (possibly canonicalized) store of the same +slot, so the first write was always immediately overwritten. + +`a.map(x => x + v)` over a 16-vs-80-element `number[]` (measured as the +marginal per-call cost difference of two probes differing only in element +count, per element, N=20000, median of 7): 450.8 -> 206.1 instructions per +element (-54.3%), control `(loop80-loop16)/64` reads -0.39 and +0.08 in the +before/after arms respectively. + +New fixture `test-files/test_gap_array_map_resolved_fill_scale.ts` exercises +sources both under and over the old 64-element boundary and the ~2048-element +born-old allocation threshold: a callback returning non-numeric values +(retiring the raw-f64 numeric claim mid-fill), one that allocates heavily to +force collections between the header resolve and the store, one that pushes +to the source mid-fill, one that truncates it mid-fill, a sparse/holey source, +and a plain numeric control. Matches node 26.5.1 and passes under seeded +moving-GC stress (`PERRY_GC_SCHEDULE_SEED`/`PERRY_GC_PROTECT_FROMSPACE`). diff --git a/crates/perry-runtime/src/array/iter_methods.rs b/crates/perry-runtime/src/array/iter_methods.rs index ae54e8b858..5a9ec36df9 100644 --- a/crates/perry-runtime/src/array/iter_methods.rs +++ b/crates/perry-runtime/src/array/iter_methods.rs @@ -1,7 +1,6 @@ //! Higher-order array methods. use super::*; use crate::closure::ClosureHeader; -use std::ptr; /// NaN-box an array header pointer as the JS `array` receiver value passed as /// the 3rd/4th callback argument (`(element, index, array)` / @@ -138,7 +137,7 @@ mod rooted_iter_array_tests { let rooted = RootedIterArray::new(&scope, arr); let mut live_arr = js_array_grow(arr, (*arr).capacity + 1); assert_ne!(live_arr, arr); - let _removed = js_array_splice(live_arr, 1, 1, ptr::null(), 0, &mut live_arr); + let _removed = js_array_splice(live_arr, 1, 1, std::ptr::null(), 0, &mut live_arr); assert_eq!((*live_arr).length, 2); assert_eq!(rooted.arr(), clean_arr_ptr(live_arr)); @@ -400,19 +399,15 @@ pub extern "C" fn js_array_map( let mapped = cb_site.call(callback, element, i as f64, rooted.receiver()); if is_plain { let result = result_arr(&result_rooted); - let result_elements = - crate::array::array_elements_ptr(result as *const ArrayHeader) as *mut f64; - // GC_STORE_AUDIT(INIT): plain result is unpublished; slot layout noted below. - ptr::write(result_elements.add(i), mapped); - let mapped_bits = mapped.to_bits(); - if length <= 64 { - // The head was just re-derived from `result_rooted`, so the - // per-element helpers' repeated ownership/forwarding proofs - // are redundant: resolve the header once. - super::header_gc_slots::fill_resolved_array_slot(result, i, mapped_bits); - } else { - note_array_slot(result, i, mapped_bits); - } + // The head was just re-derived from `result_rooted` (a GC + // root), with no intervening allocation or safepoint since — + // `fill_resolved_array_slot` satisfies exactly this contract + // regardless of the result's length, so it applies to every + // plain result, not only ones at or under some fixed size. + // It performs the element's ONLY store itself (canonicalizing + // under the array's already-known layout first), so no + // separate publish write is needed here. + super::header_gc_slots::fill_resolved_array_slot(result, i, mapped.to_bits()); } else { // Custom species container: CreateDataPropertyOrThrow via [[Set]]. crate::array::species::species_result_set( diff --git a/test-files/test_gap_array_map_resolved_fill_scale.ts b/test-files/test_gap_array_map_resolved_fill_scale.ts new file mode 100644 index 0000000000..6e30f52568 --- /dev/null +++ b/test-files/test_gap_array_map_resolved_fill_scale.ts @@ -0,0 +1,146 @@ +// `Array.prototype.map`'s plain-array fill used to resolve the result +// array's head once per element (avoiding a re-classification of the same +// pointer through `clean_arr_ptr`/`array_numeric_layout`) only for a source +// of at most 64 elements; longer sources fell back to the fully +// re-classifying `note_array_slot`. This fixture pins the fast path across +// that former boundary: sources both under and well over 64 (and over the +// ~2048-element / 16KB born-old allocation threshold), with callbacks +// designed to attack the specific risk of resolving the result header once +// per element instead of proving it fresh every store — a callback that +// allocates (forcing a collection between the resolve and the store), that +// returns non-numeric values (retiring the raw-f64 numeric claim mid-fill), +// that mutates the SOURCE by growing or truncating it out from under the +// still-running loop, and a sparse/holey source (skips must still land at +// the right index in the result). + +function range(n: number): number[] { + const a: number[] = []; + for (let i = 0; i < n; i++) a.push(i); + return a; +} + +// ---- control: plain numeric map, both sides of the old 64 cap ----------- + +const small = range(16); +console.log("ctrl-small", JSON.stringify(small.map((v) => v + 1))); + +const mid = range(65); // one past the old cap +console.log("ctrl-mid-sum", mid.map((v) => v * 2).reduce((a, b) => a + b, 0)); +console.log("ctrl-mid-ends", JSON.stringify([mid.map((v) => v * 2)[0], mid.map((v) => v * 2)[64]])); + +const big = range(500); +const bigMapped = big.map((v) => v * 3 + 1); +console.log("ctrl-big", bigMapped.length, bigMapped[0], bigMapped[250], bigMapped[499]); +console.log("ctrl-big-sum", bigMapped.reduce((a, b) => a + b, 0)); + +// Past the born-old allocation threshold (~2048 elements / 16KB of f64s): +// the RESULT array itself starts life in the old generation. +const huge = range(5000); +const hugeMapped = huge.map((v) => v + 0.25); +console.log( + "ctrl-huge", + hugeMapped.length, + hugeMapped[0], + hugeMapped[2048], + hugeMapped[4999], + hugeMapped.reduce((a, b) => a + b, 0), +); + +// ---- non-numeric return kinds: retires the raw-f64 numeric claim -------- + +const kindsSrc = range(200); +const kindsMapped = kindsSrc.map((v) => { + if (v % 5 === 0) return `s${v}`; + if (v % 5 === 1) return { v }; + if (v % 5 === 2) return undefined; + if (v % 5 === 3) return v % 2 === 0; + return v * 1.5; +}); +console.log( + "kinds", + kindsMapped.length, + typeof kindsMapped[0], + typeof kindsMapped[1], + typeof kindsMapped[2], + kindsMapped[2], + typeof kindsMapped[3], + typeof kindsMapped[4], + kindsMapped[4], +); +console.log("kinds-json", JSON.stringify(kindsMapped)); + +// -0 / NaN survive the fast path exactly. +const zeroNan = range(100).map((v) => (v === 0 ? -0 : v === 1 ? NaN : v)); +console.log("zero-nan", Object.is(zeroNan[0], -0), zeroNan[1] !== zeroNan[1], zeroNan[99]); + +// ---- callback allocates heavily: forces collections mid-fill ------------ + +const allocSrc = range(300); +const allocMapped = allocSrc.map((v) => { + const junk = new Array(48).fill({ v, pad: [v, v, v] }); + let s = 0; + for (const j of junk) s += j.v; + return s + v; +}); +console.log("alloc-len", allocMapped.length, allocMapped[0], allocMapped[149], allocMapped[299]); +console.log("alloc-sum", allocMapped.reduce((a, b) => a + b, 0)); + +// ---- callback pushes to the source mid-fill ------------------------------ + +const pushSrc = range(120); +const pushMapped = pushSrc.map((v, i) => { + if (i < 10) pushSrc.push(1000 + i); + return v; +}); +console.log("push-mapped-len", pushMapped.length, JSON.stringify(pushMapped.slice(0, 5))); +console.log("push-mapped-tail", pushMapped[119]); +console.log("push-src-len", pushSrc.length, pushSrc[120], pushSrc[129]); + +// ---- callback truncates the source mid-fill ------------------------------ + +const truncSrc = range(150); +const truncMapped = truncSrc.map((v, i) => { + if (i === 20) truncSrc.length = 60; + return v; +}); +console.log("trunc-mapped-len", truncMapped.length); +console.log("trunc-mapped-json-head", JSON.stringify(truncMapped.slice(0, 25))); +console.log("trunc-mapped-holes", 100 in truncMapped, 61 in truncMapped, 59 in truncMapped); +console.log("trunc-src-len", truncSrc.length); + +// ---- sparse / holey source, well past the old 64-element cap ------------ + +const holey: number[] = []; +holey.length = 200; +for (let i = 0; i < 200; i++) { + if (i % 7 !== 0) holey[i] = i; +} +const holeyMapped = holey.map((v) => v * 10); +console.log( + "holey-len", + holeyMapped.length, + 0 in holeyMapped, + 7 in holeyMapped, + 14 in holeyMapped, + 1 in holeyMapped, + holeyMapped[1], + holeyMapped[199], +); +console.log("holey-json", JSON.stringify(holeyMapped.slice(0, 16))); + +// A fully empty-but-long holey source: every index skipped. +const allHoles: number[] = new Array(90); +const allHolesMapped = allHoles.map((v) => v + 1); +console.log("all-holes-len", allHolesMapped.length, JSON.stringify(Object.keys(allHolesMapped))); + +// ---- frozen source, past the old cap ------------------------------------- + +const frozenBig = Object.freeze(range(90)); +console.log("frozen-big", JSON.stringify(frozenBig.map((v) => v + 1)).length, frozenBig.map((v) => v + 1)[89]); + +// ---- object-identity payloads mixed with numbers, past the old cap ------ + +const tag = { name: "shared" }; +const identitySrc = range(80); +const identityMapped = identitySrc.map((v) => (v === 40 ? tag : v)); +console.log("identity", identityMapped[40] === tag, identityMapped[39], identityMapped[41]); From 22ac7876ed99b554d959cdfeba0ad9ac236d917e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 05:09:30 +0200 Subject: [PATCH 2/2] docs: key the changelog fragment to #10577 --- ...d-fill-past-64.md => 10577-array-map-resolved-fill-past-64.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{array-map-resolved-fill-past-64.md => 10577-array-map-resolved-fill-past-64.md} (100%) diff --git a/changelog.d/array-map-resolved-fill-past-64.md b/changelog.d/10577-array-map-resolved-fill-past-64.md similarity index 100% rename from changelog.d/array-map-resolved-fill-past-64.md rename to changelog.d/10577-array-map-resolved-fill-past-64.md