diff --git a/benchmarks/array-splice-unshift-10087/.gitignore b/benchmarks/array-splice-unshift-10087/.gitignore new file mode 100644 index 0000000000..ea265de2ed --- /dev/null +++ b/benchmarks/array-splice-unshift-10087/.gitignore @@ -0,0 +1,4 @@ +array-splice-middle-remove +array-splice-middle-insert +array-unshift-build +*.exe diff --git a/benchmarks/array-splice-unshift-10087/README.md b/benchmarks/array-splice-unshift-10087/README.md new file mode 100644 index 0000000000..948fc39d34 --- /dev/null +++ b/benchmarks/array-splice-unshift-10087/README.md @@ -0,0 +1,80 @@ +# Array splice/unshift layout evidence (#10087) + +This directory preserves the three standalone issue reproducers and the raw +before/after results. Both sweeps used the unchanged TypeScript sources, +Node v26.5.1, matching release-built Perry artifacts, a 60-second timeout per +process, and a quiet Linux x86_64 host. The JSON artifacts record the exact +source, compiler, runtime, stdlib, and Node hashes, plus all seven raw samples +behind every reported median. + +## Result + +Perry milliseconds per workload invocation: + +| workload | n | main `50e08e91dd` | candidate `ae51103810` | +| --- | ---: | ---: | ---: | +| middle remove | 100 | 0.011 | 0.009 | +| | 1,000 | 0.568 | 0.111 | +| | 10,000 | 50.089 | 2.513 | +| | 100,000 | 4,943.798 | 173.967 | +| middle insert | 100 | 0.012 | 0.010 | +| | 1,000 | 0.559 | 0.107 | +| | 10,000 | 50.121 | 2.365 | +| | 100,000 | 4,924.992 | 165.801 | +| unshift build | 100 | 0.010 | 0.005 | +| | 1,000 | 0.552 | 0.072 | +| | 10,000 | 50.885 | 3.568 | +| | 100,000 | TIMEOUT | 316.252 | + +Every completed Perry checksum matches Node. All candidate processes complete +100,000 operations. Over the shared 1,000-100,000 range, the log/log slopes +and Perry-minus-Node deltas are: + +| workload | Node slope | Perry slope | delta | +| --- | ---: | ---: | ---: | +| middle remove | 1.929 | 1.597 | -0.332 | +| middle insert | 1.836 | 1.595 | -0.240 | +| unshift build | 1.878 | 1.821 | -0.057 | + +## Mechanism and bounded work + +The dense fast paths still pay their required overlapping element move. They +no longer reclassify every live slot afterward. The finisher instead: + +- classifies and barriers only newly inserted values; +- retains exact pointer-free or all-pointer metadata when the insert permits; +- drops position-specific mixed metadata to conservative UNKNOWN in constant + time; +- translates old-generation dirty-page coverage to the moved destination; +- keeps same-parent survivor translation page-only during incremental marking; and +- always revokes the conservative element-shape proof. + +Runtime unit counters cover repeated unshift, middle insertion, and middle +removal. For `n` operations they observe exactly `n` classified layout slots, +including the one-element deleted arrays produced by repeated removal, rather +than the previous sum of all live receiver lengths. + +Moving-GC tests promote an array, insert young pointers with both operations, +run a copying minor and then a full collection, and validate the rewritten +children. Old-array witnesses move old-to-young edges across remembered-set +page boundaries in both directions. A forced-evacuation splice test also proves +that its receiver and caller-provided pointer items are rooted before species +allocation. Existing splice/unshift element-shape sabotage tests continue to +prove that mixed-kind replacement cannot retain a stale proof. + +## Reproduce + +Build Perry and its matching static libraries in the checkout under test, then +run: + +```sh +python3 benchmarks/array-splice-unshift-10087/run.py \ + --perry target/release/perry \ + --node /path/to/node-v26.5.1/bin/node \ + --output benchmarks/array-splice-unshift-10087/result.json +``` + +The runner compiles all three sources with auto-optimization and the compile +cache disabled, executes sizes 100, 1,000, 10,000, and 100,000 sequentially, +checks cross-engine checksums, and records both general and acceptance-range +slopes. diff --git a/benchmarks/array-splice-unshift-10087/after.json b/benchmarks/array-splice-unshift-10087/after.json new file mode 100644 index 0000000000..b8213bfcd0 --- /dev/null +++ b/benchmarks/array-splice-unshift-10087/after.json @@ -0,0 +1,557 @@ +{ + "revision": "ae5110381005f6ccc772dc2713ddcafa98b7807b", + "node": "v26.5.1", + "host": "Linux-6.17.0-23-generic-x86_64-with-glibc2.39", + "cpu": "x86_64", + "artifact_sha256": { + "perry": "bc24599f3442c20da8d90252ac5b45836866d9c22bc009b4efc3f7a2ef332a87", + "node": "fb48e77df2f8e92fedfec39afa60a5f41563441f6b61316ada5fb295a431c2c6", + "runtime": "882ebd9ffa45f4b915517c10896737daa2b5d72987960832fb36c03506bdaba6", + "stdlib": "95dca2cfcd16106ed70cd5ee3103269bb58f03cafd46558de597870baf77fdc7", + "array-splice-middle-remove.ts": "84ddb1f66507c1c24cedb3dbc1960be4d48623bab6ec1da62bc7a53d864be69c", + "array-splice-middle-insert.ts": "ae8f30859cfe485088dea495d3e0b1ab46a23e0a46a0f9e46d405c85f610ba9c", + "array-unshift-build.ts": "5cdae1279d49a661dc46ea4fe3d06c4687b6f30c57a52e958537d2beb8619498" + }, + "workloads": { + "array-splice-middle-remove": { + "rows": [ + { + "n": 100, + "node": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 100, + "ms_per_run": 0.0025133444332745202, + "samples": [ + 0.002462353317740043, + 0.002462383232796072, + 0.002483618030547413, + 0.002514335135134819, + 0.0025196516754848143, + 0.0025137404800804793, + 0.0025133444332745202 + ], + "runs": 56107, + "checksum": 658638221, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 100, + "ms_per_run": 0.008895431302799205, + "samples": [ + 0.008828160635480226, + 0.00884902830605957, + 0.008860033658103825, + 0.009213449562412991, + 0.009122077519380533, + 0.009007542548401326, + 0.008895431302799205 + ], + "runs": 15619, + "checksum": 658638221, + "status": "OK" + } + }, + { + "n": 1000, + "node": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 1000, + "ms_per_run": 0.03807517680608502, + "samples": [ + 0.038168251908395075, + 0.03815615238095147, + 0.03802237571157435, + 0.038049735741444546, + 0.03806038022813649, + 0.03998755688622859, + 0.03807517680608502 + ], + "runs": 3655, + "checksum": 109957063, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 1000, + "ms_per_run": 0.11149689444444245, + "samples": [ + 0.11149689444444245, + 0.11219140223463442, + 0.11063083977900645, + 0.1113904277777749, + 0.11213929050279349, + 0.1106606243093896, + 0.11193697206703788 + ], + "runs": 1259, + "checksum": 109957063, + "status": "OK" + } + }, + { + "n": 10000, + "node": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 10000, + "ms_per_run": 1.7450920833333328, + "samples": [ + 1.7446035833333344, + 1.758590500000011, + 1.7461690833333374, + 1.7441420833333343, + 1.7512788333333305, + 1.7390290833333257, + 1.7450920833333328 + ], + "runs": 84, + "checksum": 733399264, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 10000, + "ms_per_run": 2.513367125000002, + "samples": [ + 2.493834333333325, + 2.522829624999993, + 2.488911999999996, + 2.513367125000002, + 2.530060750000004, + 2.5020221249999963, + 2.5154386250000016 + ], + "runs": 58, + "checksum": 733399264, + "status": "OK" + } + }, + { + "n": 100000, + "node": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 100000, + "ms_per_run": 274.1427759999997, + "samples": [ + 274.1427759999997, + 504.16258000000016, + 236.29019000000017, + 166.20478700000012, + 701.912898, + 336.29722200000015, + 173.49396499999966 + ], + "runs": 7, + "checksum": 452640523, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 100000, + "ms_per_run": 173.9668479999999, + "samples": [ + 173.9668479999999, + 174.04900199999975, + 174.45028100000013, + 174.1191819999999, + 173.77322700000013, + 173.91561300000012, + 173.83565299999987 + ], + "runs": 7, + "checksum": 452640523, + "status": "OK" + } + } + ], + "common_sizes": [ + 100, + 1000, + 10000, + 100000 + ], + "common_slopes": { + "node": 1.6774350810632053, + "perry": 1.422689161338288 + }, + "acceptance_sizes": [ + 1000, + 10000, + 100000 + ], + "acceptance_slopes": { + "node": 1.928667438735314, + "perry": 1.5966018618993696 + }, + "acceptance_slope_delta": -0.3320655768359444 + }, + "array-splice-middle-insert": { + "rows": [ + { + "n": 100, + "node": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 100, + "ms_per_run": 0.0033471489290493907, + "samples": [ + 0.0034134898585309174, + 0.0034000681625019893, + 0.0033815648351652305, + 0.00334616446377711, + 0.0033448700668894877, + 0.0033384650308802503, + 0.0033471489290493907 + ], + "runs": 41589, + "checksum": 619454386, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 100, + "ms_per_run": 0.009758514146341643, + "samples": [ + 0.009763983406540043, + 0.009777022482893027, + 0.009758514146341643, + 0.009768362304687639, + 0.009716928120445278, + 0.009732914355230464, + 0.009724182304326071 + ], + "runs": 14364, + "checksum": 619454386, + "status": "OK" + } + }, + { + "n": 1000, + "node": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 1000, + "ms_per_run": 0.04401417582417597, + "samples": [ + 0.043971690109889584, + 0.04401417582417597, + 0.0440592907488991, + 0.04405766960352186, + 0.043967314285713564, + 0.044154439293598094, + 0.04401314945054819 + ], + "runs": 3181, + "checksum": 604367096, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 1000, + "ms_per_run": 0.10689089361701487, + "samples": [ + 0.10631125925925865, + 0.10695356149732406, + 0.10703539037433057, + 0.10681245744680662, + 0.10689089361701487, + 0.10646244148936639, + 0.1070475508021358 + ], + "runs": 1314, + "checksum": 604367096, + "status": "OK" + } + }, + { + "n": 10000, + "node": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 10000, + "ms_per_run": 1.6741655000000009, + "samples": [ + 1.6741655000000009, + 1.671103999999995, + 1.6763152500000065, + 1.6730892500000039, + 1.6726736666666777, + 1.6743867499999965, + 1.6742046666666681 + ], + "runs": 84, + "checksum": 37547811, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 10000, + "ms_per_run": 2.3649916666666715, + "samples": [ + 2.3636828888888886, + 2.380627666666672, + 2.359443777777768, + 2.369723999999994, + 2.375694111111102, + 2.356767666666675, + 2.3649916666666715 + ], + "runs": 63, + "checksum": 37547811, + "status": "OK" + } + }, + { + "n": 100000, + "node": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 100000, + "ms_per_run": 206.45521999999983, + "samples": [ + 252.36218399999984, + 338.75615200000016, + 159.05078099999992, + 159.331956, + 159.50258499999995, + 206.45521999999983, + 215.29910100000006 + ], + "runs": 7, + "checksum": 275047240, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 100000, + "ms_per_run": 165.80140400000005, + "samples": [ + 165.65149500000007, + 165.37159099999997, + 165.80991999999992, + 165.80140400000005, + 165.94846800000005, + 165.87510099999986, + 165.4396290000002 + ], + "runs": 7, + "checksum": 275047240, + "status": "OK" + } + } + ], + "common_sizes": [ + 100, + 1000, + 10000, + 100000 + ], + "common_slopes": { + "node": 1.5950658308870878, + "perry": 1.4035502430328335 + }, + "acceptance_sizes": [ + 1000, + 10000, + 100000 + ], + "acceptance_slopes": { + "node": 1.8356166470102018, + "perry": 1.595323747997074 + }, + "acceptance_slope_delta": -0.24029289901312767 + }, + "array-unshift-build": { + "rows": [ + { + "n": 100, + "node": { + "name": "array-unshift-build", + "category": "arrays", + "n": 100, + "ms_per_run": 0.0033775864572776174, + "samples": [ + 0.0033740381241561795, + 0.0033775864572776174, + 0.003614731429603682, + 0.0034307938604013397, + 0.003363807265388638, + 0.0033633991928705286, + 0.003377586963863733 + ], + "runs": 41029, + "checksum": 922626605, + "status": "OK" + }, + "perry": { + "name": "array-unshift-build", + "category": "arrays", + "n": 100, + "ms_per_run": 0.0049986651674162365, + "samples": [ + 0.004988065087281702, + 0.005792555169417779, + 0.0049986651674162365, + 0.004974666500870215, + 0.005874302202643309, + 0.004969518757763908, + 0.005762233074041185 + ], + "runs": 26387, + "checksum": 922626605, + "status": "OK" + } + }, + { + "n": 1000, + "node": { + "name": "array-unshift-build", + "category": "arrays", + "n": 1000, + "ms_per_run": 0.05683482102272757, + "samples": [ + 0.057129615384615445, + 0.05712500569800549, + 0.05734376790831055, + 0.05683482102272757, + 0.05671412464589394, + 0.056604999999998976, + 0.056568836158191325 + ], + "runs": 2464, + "checksum": 414934349, + "status": "OK" + }, + "perry": { + "name": "array-unshift-build", + "category": "arrays", + "n": 1000, + "ms_per_run": 0.072260523465705, + "samples": [ + 0.07215888848920934, + 0.073547874999998, + 0.07215744244604148, + 0.07254304710144786, + 0.07214932374100771, + 0.07347654212454112, + 0.072260523465705 + ], + "runs": 1932, + "checksum": 414934349, + "status": "OK" + } + }, + { + "n": 10000, + "node": { + "name": "array-unshift-build", + "category": "arrays", + "n": 10000, + "ms_per_run": 3.3754080000000215, + "samples": [ + 3.404167166666672, + 3.3930141666666693, + 3.3754080000000215, + 3.377228333333344, + 3.3626045000000033, + 3.372030333333337, + 3.370495333333338 + ], + "runs": 42, + "checksum": 183209813, + "status": "OK" + }, + "perry": { + "name": "array-unshift-build", + "category": "arrays", + "n": 10000, + "ms_per_run": 3.567977833333335, + "samples": [ + 3.5646998333333264, + 3.5661075000000104, + 3.584528666666685, + 3.5982860000000016, + 3.567977833333335, + 3.5903093333333325, + 3.5592214999999974 + ], + "runs": 42, + "checksum": 183209813, + "status": "OK" + } + }, + { + "n": 100000, + "node": { + "name": "array-unshift-build", + "category": "arrays", + "n": 100000, + "ms_per_run": 323.362564, + "samples": [ + 323.2890970000001, + 323.362564, + 324.18969800000013, + 322.71431399999983, + 323.78218700000025, + 322.60169399999995, + 325.9033370000002 + ], + "runs": 7, + "checksum": 622348785, + "status": "OK" + }, + "perry": { + "name": "array-unshift-build", + "category": "arrays", + "n": 100000, + "ms_per_run": 316.25168900000017, + "samples": [ + 317.29108899999983, + 316.492569, + 316.1632940000004, + 316.6058000000003, + 316.25168900000017, + 315.88338099999964, + 316.20965099999967 + ], + "runs": 7, + "checksum": 622348785, + "status": "OK" + } + } + ], + "common_sizes": [ + 100, + 1000, + 10000, + 100000 + ], + "common_slopes": { + "node": 1.6716961574139262, + "perry": 1.6097057464790885 + }, + "acceptance_sizes": [ + 1000, + 10000, + 100000 + ], + "acceptance_slopes": { + "node": 1.877537621539156, + "perry": 1.820565875141814 + }, + "acceptance_slope_delta": -0.05697174639734204 + } + } +} diff --git a/benchmarks/array-splice-unshift-10087/array-splice-middle-insert.ts b/benchmarks/array-splice-unshift-10087/array-splice-middle-insert.ts new file mode 100644 index 0000000000..202a76b18d --- /dev/null +++ b/benchmarks/array-splice-unshift-10087/array-splice-middle-insert.ts @@ -0,0 +1,91 @@ +// @runtime {"name": "array-splice-middle-insert", "category": "arrays", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/array/splice_slice.rs", "function": "js_array_splice"}], "hypothesis": "Every middle insertion creates a deleted-elements array, memmoves the tail and rebuilds the live array layout.", "notes": "n single-element insertions build an initially empty array; the input values are prepared before timing.", "asynchronous": false, "output_stderr": false, "fresh_input": false} +// Standalone file. Shared helpers/driver are inlined by common.py. + +let seed = 0x12345678; +function rnd(): number { + seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; + return (seed >>> 0) / 4294967296; +} +function numbers(n: number): number[] { + const a: number[] = []; + for (let i = 0; i < n; i++) a.push(Math.floor(rnd() * 1000000)); + return a; +} +function hashArray(a: number[]): number { + let h = a.length; + for (let i = 0; i < a.length; i++) h = (h * 31 + a[i]) % 1000000007; + return h; +} +// Bounded checksum work avoids making string slicing/indexing part of every +// string benchmark's asymptotic cost. The workload itself consumes its result. +function hashString(s: string): number { + let h = s.length; + const step = Math.max(1, Math.floor(s.length / 32)); + for (let i = 0; i < s.length; i += step) h = (h * 31 + s.charCodeAt(i)) % 1000000007; + return h; +} + +function setup(n: number): number[] { return numbers(n); } + +function run(input: number[]): number { + const a: number[] = []; + for (let i = 0; i < input.length; i++) a.splice(Math.floor(a.length / 2), 0, input[i]); + return hashArray(a); +} + +// Size is the final argument: both native Perry and Node expose it reliably. +const n = Number(process.argv[process.argv.length - 1]); +if (!(n > 0)) throw new Error("Expected a positive size argument"); +function benchmarkMain(): void { + seed = 0x12345678; + const preparedInput = setup(n); + let checksum = 0; + let seen = false; + let warmMs = 0; + let warmRuns = 0; + while (warmMs < 200 || warmRuns < 5) { + seed = 0x12345678; + const input = preparedInput; + const start = performance.now(); + const value = run(input); + const elapsed = performance.now() - start; + if (!(elapsed >= 0)) throw new Error("Invalid monotonic timer"); + warmMs += elapsed; + warmRuns++; + if (seen && value !== checksum) throw new Error("CORRECTNESS: unstable checksum during warmup"); + checksum = value; + seen = true; + } + const samples: number[] = []; + let runs = 0; + for (let sample = 0; sample < 7; sample++) { + let elapsed = 0; + let count = 0; + // Mutable workloads prepare fresh input BEFORE each timer; immutable + // workloads reuse setup. Neither preparation nor validation is measured. + while (elapsed < 20) { + seed = 0x12345678; + const input = preparedInput; + const start = performance.now(); + const value = run(input); + const duration = performance.now() - start; + if (!(duration >= 0)) throw new Error("Invalid monotonic timer"); + elapsed += duration; + count++; + if (value !== checksum) throw new Error("CORRECTNESS: unstable checksum during sampling"); + } + samples.push(elapsed / count); + runs += count; + } + // Do not depend on Array.sort to compute the median of a sort benchmark. + const sortedSamples = samples.slice(); + for (let i = 1; i < sortedSamples.length; i++) { + const v = sortedSamples[i]; + let j = i - 1; + while (j >= 0 && sortedSamples[j] > v) { sortedSamples[j + 1] = sortedSamples[j]; j--; } + sortedSamples[j + 1] = v; + } + console.log(JSON.stringify({name: "array-splice-middle-insert", category: "arrays", n, + ms_per_run: sortedSamples[3], samples, runs, checksum})); +} +benchmarkMain(); diff --git a/benchmarks/array-splice-unshift-10087/array-splice-middle-remove.ts b/benchmarks/array-splice-unshift-10087/array-splice-middle-remove.ts new file mode 100644 index 0000000000..2f379a9dcf --- /dev/null +++ b/benchmarks/array-splice-unshift-10087/array-splice-middle-remove.ts @@ -0,0 +1,94 @@ +// @runtime {"name": "array-splice-middle-remove", "category": "arrays", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/array/splice_slice.rs", "function": "js_array_splice"}], "hypothesis": "Every middle removal allocates the return array, memmoves the surviving tail and rebuilds the live array layout.", "notes": "n single-element middle removals drain a fresh input array.", "asynchronous": false, "output_stderr": false, "fresh_input": true} +// Standalone file. Shared helpers/driver are inlined by common.py. + +let seed = 0x12345678; +function rnd(): number { + seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; + return (seed >>> 0) / 4294967296; +} +function numbers(n: number): number[] { + const a: number[] = []; + for (let i = 0; i < n; i++) a.push(Math.floor(rnd() * 1000000)); + return a; +} +function hashArray(a: number[]): number { + let h = a.length; + for (let i = 0; i < a.length; i++) h = (h * 31 + a[i]) % 1000000007; + return h; +} +// Bounded checksum work avoids making string slicing/indexing part of every +// string benchmark's asymptotic cost. The workload itself consumes its result. +function hashString(s: string): number { + let h = s.length; + const step = Math.max(1, Math.floor(s.length / 32)); + for (let i = 0; i < s.length; i += step) h = (h * 31 + s.charCodeAt(i)) % 1000000007; + return h; +} + +function setup(n: number): number[] { return numbers(n); } + +function run(a: number[]): number { + let h = 0; + while (a.length) { + const removed = a.splice(Math.floor(a.length / 2), 1); + h = (h * 31 + removed[0]) % 1000000007; + } + return h; +} + +// Size is the final argument: both native Perry and Node expose it reliably. +const n = Number(process.argv[process.argv.length - 1]); +if (!(n > 0)) throw new Error("Expected a positive size argument"); +function benchmarkMain(): void { + seed = 0x12345678; + + let checksum = 0; + let seen = false; + let warmMs = 0; + let warmRuns = 0; + while (warmMs < 200 || warmRuns < 5) { + seed = 0x12345678; + const input = setup(n); + const start = performance.now(); + const value = run(input); + const elapsed = performance.now() - start; + if (!(elapsed >= 0)) throw new Error("Invalid monotonic timer"); + warmMs += elapsed; + warmRuns++; + if (seen && value !== checksum) throw new Error("CORRECTNESS: unstable checksum during warmup"); + checksum = value; + seen = true; + } + const samples: number[] = []; + let runs = 0; + for (let sample = 0; sample < 7; sample++) { + let elapsed = 0; + let count = 0; + // Mutable workloads prepare fresh input BEFORE each timer; immutable + // workloads reuse setup. Neither preparation nor validation is measured. + while (elapsed < 20) { + seed = 0x12345678; + const input = setup(n); + const start = performance.now(); + const value = run(input); + const duration = performance.now() - start; + if (!(duration >= 0)) throw new Error("Invalid monotonic timer"); + elapsed += duration; + count++; + if (value !== checksum) throw new Error("CORRECTNESS: unstable checksum during sampling"); + } + samples.push(elapsed / count); + runs += count; + } + // Do not depend on Array.sort to compute the median of a sort benchmark. + const sortedSamples = samples.slice(); + for (let i = 1; i < sortedSamples.length; i++) { + const v = sortedSamples[i]; + let j = i - 1; + while (j >= 0 && sortedSamples[j] > v) { sortedSamples[j + 1] = sortedSamples[j]; j--; } + sortedSamples[j + 1] = v; + } + console.log(JSON.stringify({name: "array-splice-middle-remove", category: "arrays", n, + ms_per_run: sortedSamples[3], samples, runs, checksum})); +} +benchmarkMain(); diff --git a/benchmarks/array-splice-unshift-10087/array-unshift-build.ts b/benchmarks/array-splice-unshift-10087/array-unshift-build.ts new file mode 100644 index 0000000000..5e9431d3a3 --- /dev/null +++ b/benchmarks/array-splice-unshift-10087/array-unshift-build.ts @@ -0,0 +1,91 @@ +// @runtime {"name": "array-unshift-build", "category": "arrays", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/array/push_pop.rs", "function": "js_array_unshift_f64"}], "hypothesis": "Each unshift memmoves the whole live prefix and rebuilds its array layout, making repeated front insertion quadratic.", "notes": "", "asynchronous": false, "output_stderr": false, "fresh_input": false} +// Standalone file. Shared helpers/driver are inlined by common.py. + +let seed = 0x12345678; +function rnd(): number { + seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; + return (seed >>> 0) / 4294967296; +} +function numbers(n: number): number[] { + const a: number[] = []; + for (let i = 0; i < n; i++) a.push(Math.floor(rnd() * 1000000)); + return a; +} +function hashArray(a: number[]): number { + let h = a.length; + for (let i = 0; i < a.length; i++) h = (h * 31 + a[i]) % 1000000007; + return h; +} +// Bounded checksum work avoids making string slicing/indexing part of every +// string benchmark's asymptotic cost. The workload itself consumes its result. +function hashString(s: string): number { + let h = s.length; + const step = Math.max(1, Math.floor(s.length / 32)); + for (let i = 0; i < s.length; i += step) h = (h * 31 + s.charCodeAt(i)) % 1000000007; + return h; +} + +function setup(n: number): number[] { return numbers(n); } + +function run(input: number[]): number { + const a: number[] = []; + for (let i = 0; i < input.length; i++) a.unshift(input[i]); + return hashArray(a); +} + +// Size is the final argument: both native Perry and Node expose it reliably. +const n = Number(process.argv[process.argv.length - 1]); +if (!(n > 0)) throw new Error("Expected a positive size argument"); +function benchmarkMain(): void { + seed = 0x12345678; + const preparedInput = setup(n); + let checksum = 0; + let seen = false; + let warmMs = 0; + let warmRuns = 0; + while (warmMs < 200 || warmRuns < 5) { + seed = 0x12345678; + const input = preparedInput; + const start = performance.now(); + const value = run(input); + const elapsed = performance.now() - start; + if (!(elapsed >= 0)) throw new Error("Invalid monotonic timer"); + warmMs += elapsed; + warmRuns++; + if (seen && value !== checksum) throw new Error("CORRECTNESS: unstable checksum during warmup"); + checksum = value; + seen = true; + } + const samples: number[] = []; + let runs = 0; + for (let sample = 0; sample < 7; sample++) { + let elapsed = 0; + let count = 0; + // Mutable workloads prepare fresh input BEFORE each timer; immutable + // workloads reuse setup. Neither preparation nor validation is measured. + while (elapsed < 20) { + seed = 0x12345678; + const input = preparedInput; + const start = performance.now(); + const value = run(input); + const duration = performance.now() - start; + if (!(duration >= 0)) throw new Error("Invalid monotonic timer"); + elapsed += duration; + count++; + if (value !== checksum) throw new Error("CORRECTNESS: unstable checksum during sampling"); + } + samples.push(elapsed / count); + runs += count; + } + // Do not depend on Array.sort to compute the median of a sort benchmark. + const sortedSamples = samples.slice(); + for (let i = 1; i < sortedSamples.length; i++) { + const v = sortedSamples[i]; + let j = i - 1; + while (j >= 0 && sortedSamples[j] > v) { sortedSamples[j + 1] = sortedSamples[j]; j--; } + sortedSamples[j + 1] = v; + } + console.log(JSON.stringify({name: "array-unshift-build", category: "arrays", n, + ms_per_run: sortedSamples[3], samples, runs, checksum})); +} +benchmarkMain(); diff --git a/benchmarks/array-splice-unshift-10087/before.json b/benchmarks/array-splice-unshift-10087/before.json new file mode 100644 index 0000000000..6d2e7fd666 --- /dev/null +++ b/benchmarks/array-splice-unshift-10087/before.json @@ -0,0 +1,540 @@ +{ + "revision": "50e08e91dd6a54d9d9210c43a5d36c86d880d144", + "node": "v26.5.1", + "host": "Linux-6.17.0-23-generic-x86_64-with-glibc2.39", + "cpu": "x86_64", + "artifact_sha256": { + "perry": "085b75cf1739f0bb5b27ad4113b95de8e20bc4e8778d41f5ddd332cfdf4a380d", + "node": "fb48e77df2f8e92fedfec39afa60a5f41563441f6b61316ada5fb295a431c2c6", + "runtime": "e6f01dc8df48a54b719c9dd53e1b39869db38d6c74005728a9011cc751a3afa4", + "stdlib": "7ed97869f80fe648a6751bfbbb7f4c3e3ff930f82097b30f8c9e92cb3ebff632", + "array-splice-middle-remove.ts": "84ddb1f66507c1c24cedb3dbc1960be4d48623bab6ec1da62bc7a53d864be69c", + "array-splice-middle-insert.ts": "ae8f30859cfe485088dea495d3e0b1ab46a23e0a46a0f9e46d405c85f610ba9c", + "array-unshift-build.ts": "5cdae1279d49a661dc46ea4fe3d06c4687b6f30c57a52e958537d2beb8619498" + }, + "workloads": { + "array-splice-middle-remove": { + "rows": [ + { + "n": 100, + "node": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 100, + "ms_per_run": 0.00249472371211187, + "samples": [ + 0.002435937522835471, + 0.002449708389466883, + 0.002471917439130469, + 0.0024977552447554728, + 0.002500145874999632, + 0.0025053710384564947, + 0.00249472371211187 + ], + "runs": 56475, + "checksum": 658638221, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 100, + "ms_per_run": 0.011383208309618144, + "samples": [ + 0.01143070114285797, + 0.011408033637399792, + 0.011277388951523061, + 0.011383208309618144, + 0.011377003981797099, + 0.01141198060467844, + 0.011331552661380535 + ], + "runs": 12312, + "checksum": 658638221, + "status": "OK" + } + }, + { + "n": 1000, + "node": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 1000, + "ms_per_run": 0.03797202277039719, + "samples": [ + 0.037951155597723336, + 0.03796083870967935, + 0.03797202277039719, + 0.0379825616698285, + 0.03798871347248495, + 0.04004274000000078, + 0.037727548022597444 + ], + "runs": 3666, + "checksum": 109957063, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 1000, + "ms_per_run": 0.5676167222222183, + "samples": [ + 0.5669796111111067, + 0.5668073333333391, + 0.5711263055555489, + 0.5681611111111111, + 0.5676167222222183, + 0.5710699444444474, + 0.566894999999996 + ], + "runs": 252, + "checksum": 109957063, + "status": "OK" + } + }, + { + "n": 10000, + "node": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 10000, + "ms_per_run": 1.7079852500000026, + "samples": [ + 1.7042727500000012, + 1.7083501666666667, + 1.7037935000000033, + 1.7079852500000026, + 1.709773750000006, + 1.7070161666666621, + 1.7113901666666749 + ], + "runs": 84, + "checksum": 733399264, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 10000, + "ms_per_run": 50.08937000000003, + "samples": [ + 49.99679699999999, + 50.07237700000002, + 50.18052, + 50.08937000000003, + 50.101421000000016, + 50.04572800000011, + 51.700834999999984 + ], + "runs": 7, + "checksum": 733399264, + "status": "OK" + } + }, + { + "n": 100000, + "node": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 100000, + "ms_per_run": 235.17973500000016, + "samples": [ + 273.21835899999996, + 498.9045689999998, + 235.17973500000016, + 164.49285999999984, + 163.12091099999998, + 371.33156800000006, + 181.43499800000018 + ], + "runs": 7, + "checksum": 452640523, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-remove", + "category": "arrays", + "n": 100000, + "ms_per_run": 4943.797626999993, + "samples": [ + 4937.649572000002, + 4939.901297999997, + 4943.142349000009, + 4950.355308000006, + 4945.045659000003, + 4945.771986, + 4943.797626999993 + ], + "runs": 7, + "checksum": 452640523, + "status": "OK" + } + } + ], + "common_sizes": [ + 100, + 1000, + 10000, + 100000 + ], + "common_slopes": { + "node": 1.6576152708249774, + "perry": 1.8859078391884527 + }, + "acceptance_sizes": [ + 1000, + 10000, + 100000 + ], + "acceptance_slopes": { + "node": 1.8959680820188138, + "perry": 1.9700027516255207 + }, + "acceptance_slope_delta": 0.0740346696067069 + }, + "array-splice-middle-insert": { + "rows": [ + { + "n": 100, + "node": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 100, + "ms_per_run": 0.003334171028504863, + "samples": [ + 0.0033959791171476847, + 0.003395647368421259, + 0.0033672392255889843, + 0.0033270003325575354, + 0.003332811198133428, + 0.003334171028504863, + 0.00333260863045679 + ], + "runs": 41736, + "checksum": 619454386, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 100, + "ms_per_run": 0.01175000469759223, + "samples": [ + 0.01179172127283419, + 0.01175000469759223, + 0.011739468309858185, + 0.011673562427072308, + 0.01182523345153636, + 0.011796361438679365, + 0.011681729713950909 + ], + "runs": 11919, + "checksum": 619454386, + "status": "OK" + } + }, + { + "n": 1000, + "node": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 1000, + "ms_per_run": 0.043903504385966505, + "samples": [ + 0.043887703947368455, + 0.04395610769230603, + 0.04380913566739684, + 0.043903504385966505, + 0.04392143201754369, + 0.04384778774617051, + 0.04390995394736881 + ], + "runs": 3193, + "checksum": 604367096, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 1000, + "ms_per_run": 0.5587826111111119, + "samples": [ + 0.558756416666665, + 0.5587826111111119, + 0.5624807777777758, + 0.558431083333335, + 0.5613378055555608, + 0.5575438333333315, + 0.5610957499999958 + ], + "runs": 252, + "checksum": 604367096, + "status": "OK" + } + }, + { + "n": 10000, + "node": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 10000, + "ms_per_run": 1.6686945000000104, + "samples": [ + 1.6703240000000033, + 1.6681704999999927, + 1.6849644999999878, + 1.6688192500000032, + 1.6681629999999972, + 1.667304083333325, + 1.6686945000000104 + ], + "runs": 85, + "checksum": 37547811, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 10000, + "ms_per_run": 50.12064800000002, + "samples": [ + 50.00114599999998, + 50.008609000000035, + 50.13255000000004, + 50.214883999999984, + 50.12064800000002, + 49.88314500000001, + 51.556456000000026 + ], + "runs": 7, + "checksum": 37547811, + "status": "OK" + } + }, + { + "n": 100000, + "node": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 100000, + "ms_per_run": 205.96261600000025, + "samples": [ + 266.61390600000004, + 356.35086, + 159.38835199999994, + 158.8593760000001, + 158.71071800000004, + 205.96261600000025, + 215.29663699999992 + ], + "runs": 7, + "checksum": 275047240, + "status": "OK" + }, + "perry": { + "name": "array-splice-middle-insert", + "category": "arrays", + "n": 100000, + "ms_per_run": 4924.99224, + "samples": [ + 4925.511021999999, + 4924.99224, + 4924.786892999997, + 4926.199474000001, + 4924.712635999997, + 4935.136634999995, + 4924.367428999998 + ], + "runs": 7, + "checksum": 275047240, + "status": "OK" + } + } + ], + "common_sizes": [ + 100, + 1000, + 10000, + 100000 + ], + "common_slopes": { + "node": 1.5952279224896049, + "perry": 1.8819876325448712 + }, + "acceptance_sizes": [ + 1000, + 10000, + 100000 + ], + "acceptance_slopes": { + "node": 1.8356446061083644, + "perry": 1.9725813339022467 + }, + "acceptance_slope_delta": 0.13693672779388222 + }, + "array-unshift-build": { + "rows": [ + { + "n": 100, + "node": { + "name": "array-unshift-build", + "category": "arrays", + "n": 100, + "ms_per_run": 0.0033406375480206107, + "samples": [ + 0.003368958228061221, + 0.0033633711114843864, + 0.003359026196473759, + 0.0033406375480206107, + 0.003335973148765855, + 0.0033394645182839243, + 0.0033378830106807044 + ], + "runs": 41803, + "checksum": 922626605, + "status": "OK" + }, + "perry": { + "name": "array-unshift-build", + "category": "arrays", + "n": 100, + "ms_per_run": 0.009645986981678203, + "samples": [ + 0.009630325950890554, + 0.009626392685274772, + 0.010131870048308933, + 0.009661437952680756, + 0.009654288127413245, + 0.009645986981678203, + 0.009643567984570967 + ], + "runs": 14516, + "checksum": 922626605, + "status": "OK" + } + }, + { + "n": 1000, + "node": { + "name": "array-unshift-build", + "category": "arrays", + "n": 1000, + "ms_per_run": 0.05584066295264678, + "samples": [ + 0.05593236312849067, + 0.05595974301675869, + 0.05584066295264678, + 0.055909893854748254, + 0.055752311977715435, + 0.05580886629526279, + 0.055813281337047674 + ], + "runs": 2510, + "checksum": 414934349, + "status": "OK" + }, + "perry": { + "name": "array-unshift-build", + "category": "arrays", + "n": 1000, + "ms_per_run": 0.5524155945945787, + "samples": [ + 0.5519921621621637, + 0.5522566756756746, + 0.5544745405405482, + 0.5515356486486406, + 0.5524155945945787, + 0.5541402162162168, + 0.5541565135135114 + ], + "runs": 259, + "checksum": 414934349, + "status": "OK" + } + }, + { + "n": 10000, + "node": { + "name": "array-unshift-build", + "category": "arrays", + "n": 10000, + "ms_per_run": 3.3230101428571452, + "samples": [ + 3.3717298333333283, + 3.3454908333333435, + 3.3168474285714393, + 3.319139000000007, + 3.3230101428571452, + 3.323241857142859, + 3.315325999999987 + ], + "runs": 47, + "checksum": 183209813, + "status": "OK" + }, + "perry": { + "name": "array-unshift-build", + "category": "arrays", + "n": 10000, + "ms_per_run": 50.88452100000001, + "samples": [ + 50.88486299999997, + 50.95875100000001, + 50.88452100000001, + 50.83417799999995, + 50.878190000000075, + 50.93173099999996, + 50.864754999999946 + ], + "runs": 7, + "checksum": 183209813, + "status": "OK" + } + }, + { + "n": 100000, + "node": { + "name": "array-unshift-build", + "category": "arrays", + "n": 100000, + "ms_per_run": 316.8560090000001, + "samples": [ + 320.27808200000004, + 316.5288189999999, + 316.8560090000001, + 320.45427100000006, + 316.6757640000001, + 320.1764440000002, + 316.3009940000002 + ], + "runs": 7, + "checksum": 622348785, + "status": "OK" + }, + "perry": { + "status": "TIMEOUT" + } + } + ], + "common_sizes": [ + 100, + 1000, + 10000 + ], + "common_slopes": { + "node": 1.498851154351971, + "perry": 1.8611195095979047 + }, + "acceptance_sizes": [ + 1000, + 10000 + ], + "acceptance_slopes": { + "node": 1.7745811011966977, + "perry": 1.964319760517739 + }, + "acceptance_slope_delta": 0.18973865932104128 + } + } +} diff --git a/benchmarks/array-splice-unshift-10087/run.py b/benchmarks/array-splice-unshift-10087/run.py new file mode 100644 index 0000000000..567db62857 --- /dev/null +++ b/benchmarks/array-splice-unshift-10087/run.py @@ -0,0 +1,147 @@ +"""Sequential checksum-gated issue #10087 benchmark (60 s per process).""" +import argparse +import hashlib +import json +import math +import os +from pathlib import Path +import platform +import subprocess + + +WORKLOADS = ( + "array-splice-middle-remove", + "array-splice-middle-insert", + "array-unshift-build", +) +ACCEPTANCE_SIZES = (1000, 10000, 100000) + + +def artifact_hashes(perry, node, sources): + windows = os.name == "nt" + paths = {"perry": perry, "node": Path(node)} + for name in ("runtime", "stdlib"): + paths[name] = perry.parent / (f"perry_{name}.lib" if windows else f"libperry_{name}.a") + paths.update({source.name: source for source in sources}) + return {name: hashlib.sha256(path.read_bytes()).hexdigest() for name, path in paths.items()} + + +def slope(rows): + if len(rows) < 2: + return None + x = [math.log(n) for n, _ in rows] + y = [math.log(t) for _, t in rows] + mx, my = sum(x) / len(x), sum(y) / len(y) + return sum((a - mx) * (b - my) for a, b in zip(x, y)) / sum( + (a - mx) ** 2 for a in x + ) + + +def acceptance_summary(common): + acceptance = [row for row in common if row["n"] in ACCEPTANCE_SIZES] + acceptance_complete = [row["n"] for row in acceptance] == list(ACCEPTANCE_SIZES) + slopes = { + engine: ( + slope([(row["n"], row[engine]["ms_per_run"]) for row in acceptance]) + if acceptance_complete + else None + ) + for engine in ("node", "perry") + } + delta = slopes["perry"] - slopes["node"] if acceptance_complete else None + return [row["n"] for row in acceptance], slopes, delta + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--perry", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--node", default="node") + args = parser.parse_args() + args.node = subprocess.check_output([args.node, "-p", "process.execPath"], text=True).strip() + root = Path(__file__).resolve().parent + sources = [root / f"{name}.ts" for name in WORKLOADS] + env = dict( + os.environ, + PERRY_RUNTIME_DIR=str(args.perry.resolve().parent), + TZ="UTC", + LC_ALL="en_US.UTF-8", + ) + result = { + "revision": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), + "node": subprocess.check_output([args.node, "--version"], text=True).strip(), + "host": platform.platform(), + "cpu": platform.processor(), + "artifact_sha256": artifact_hashes(args.perry.resolve(), args.node, sources), + "workloads": {}, + } + for name in WORKLOADS: + source = root / f"{name}.ts" + binary = args.output.resolve().parent / (name + (".exe" if os.name == "nt" else "")) + subprocess.run( + [ + str(args.perry.resolve()), + "compile", + str(source), + "--no-auto-optimize", + "--no-cache", + "-o", + str(binary), + ], + env=env, + check=True, + ) + rows, stopped = [], set() + for n in (100, 1000, 10000, 100000): + pair = {} + for engine, command in ( + ("node", [args.node, str(source)]), + ("perry", [str(binary)]), + ): + if engine in stopped: + row = {"status": "SKIPPED"} + else: + try: + process = subprocess.run( + command + [str(n)], + env=env, + capture_output=True, + text=True, + timeout=60, + ) + except subprocess.TimeoutExpired: + stopped.add(engine) + row = {"status": "TIMEOUT"} + else: + if process.returncode: + raise RuntimeError( + f"{engine} {name} {n}: {process.returncode}\n" + f"{process.stdout}\n{process.stderr}" + ) + row = dict(json.loads(process.stdout), status="OK") + pair[engine] = row + print(name, n, engine, json.dumps(row), flush=True) + if all(pair[engine]["status"] == "OK" for engine in ("node", "perry")): + assert pair["node"]["checksum"] == pair["perry"]["checksum"], pair + rows.append({"n": n, **pair}) + result["workloads"][name] = {"rows": rows} + args.output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + common = [row for row in rows if all(row[e]["status"] == "OK" for e in ("node", "perry"))] + result["workloads"][name].update( + common_sizes=[row["n"] for row in common], + common_slopes={ + engine: slope([(row["n"], row[engine]["ms_per_run"]) for row in common]) + for engine in ("node", "perry") + }, + ) + acceptance_sizes, acceptance_slopes, acceptance_slope_delta = acceptance_summary(common) + result["workloads"][name].update( + acceptance_sizes=acceptance_sizes, + acceptance_slopes=acceptance_slopes, + acceptance_slope_delta=acceptance_slope_delta, + ) + args.output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/changelog.d/10126-array-splice-unshift-layout.md b/changelog.d/10126-array-splice-unshift-layout.md new file mode 100644 index 0000000000..c6e7a8f8d7 --- /dev/null +++ b/changelog.d/10126-array-splice-unshift-layout.md @@ -0,0 +1,7 @@ +Make dense `Array.splice()` and `Array.unshift()` update GC element metadata +from only the inserted slots instead of rebuilding it from every live element. +Pointer-free and all-pointer layouts remain exact, mixed layouts fall back to +conservative scanning, and old-array dirty-page coverage follows moved +survivors. Splice and variadic-unshift inputs are rooted across allocating +steps. Repeated middle splice and front insertion no longer time out at 100,000 +operations. diff --git a/crates/perry-runtime/src/array/dense_move_tests.rs b/crates/perry-runtime/src/array/dense_move_tests.rs new file mode 100644 index 0000000000..40a028d138 --- /dev/null +++ b/crates/perry-runtime/src/array/dense_move_tests.rs @@ -0,0 +1,90 @@ +use super::header::array_numeric_layout; +use super::header_gc_slots::{ + test_dense_move_layout_classified_slots, test_reset_dense_move_layout_classified_slots, +}; +use super::*; + +#[test] +fn repeated_dense_unshift_classifies_only_the_inserted_slots() { + let _triggers = crate::gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + const N: usize = 512; + let arr = js_array_alloc(N as u32); + + test_reset_dense_move_layout_classified_slots(); + for value in 0..N { + assert_eq!(js_array_unshift_f64(arr, value as f64), arr); + } + + assert_eq!(test_dense_move_layout_classified_slots(), N); + assert_eq!(js_array_length(arr), N as u32); + for index in 0..N { + assert_eq!(js_array_get_f64(arr, index as u32), (N - index - 1) as f64); + } +} + +#[test] +fn repeated_dense_splice_layout_work_is_linear_in_operations() { + let _triggers = crate::gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + const N: usize = 256; + + let inserted = js_array_alloc(N as u32); + test_reset_dense_move_layout_classified_slots(); + for value in 0..N { + let item = [value as f64]; + let mut out = inserted; + let deleted = js_array_splice(inserted, (value / 2) as i32, 0, item.as_ptr(), 1, &mut out); + assert_eq!(out, inserted); + assert_eq!(js_array_length(deleted), 0); + } + assert_eq!(test_dense_move_layout_classified_slots(), N); + + let removed = js_array_alloc(N as u32); + for value in 0..N { + assert_eq!(js_array_push_f64(removed, value as f64), removed); + } + test_reset_dense_move_layout_classified_slots(); + for _ in 0..N { + let mut out = removed; + let deleted = js_array_splice( + removed, + (js_array_length(removed) / 2) as i32, + 1, + std::ptr::null(), + 0, + &mut out, + ); + assert_eq!(out, removed); + assert_eq!(js_array_length(deleted), 1); + } + assert_eq!(test_dense_move_layout_classified_slots(), N); + assert_eq!(js_array_length(removed), 0); +} + +#[test] +fn dense_moves_preserve_the_existing_numeric_layout_without_a_rebuild() { + let _triggers = crate::gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let arr = js_array_alloc(16); + for value in [1.0, 2.0, 3.0, 4.0] { + assert_eq!(js_array_push_f64(arr, value), arr); + } + assert_eq!( + unsafe { array_numeric_layout(arr) }, + Some(NumericArrayLayout::RawF64) + ); + + let arr = js_array_unshift_f64(arr, f64::from_bits(crate::value::JSValue::int32(0).bits())); + assert_eq!( + unsafe { array_numeric_layout(arr) }, + Some(NumericArrayLayout::RawF64) + ); + assert_eq!(js_array_get_f64(arr, 0), 0.0); + + let item = [f64::from_bits(crate::value::JSValue::int32(9).bits())]; + let mut out = arr; + js_array_splice(arr, 2, 1, item.as_ptr(), 1, &mut out); + assert_eq!( + unsafe { array_numeric_layout(out) }, + Some(NumericArrayLayout::RawF64) + ); + assert_eq!(js_array_get_f64(out, 2), 9.0); +} diff --git a/crates/perry-runtime/src/array/element_shape_matrix_tests.rs b/crates/perry-runtime/src/array/element_shape_matrix_tests.rs index e40d1a989b..dc862cae3e 100644 --- a/crates/perry-runtime/src/array/element_shape_matrix_tests.rs +++ b/crates/perry-runtime/src/array/element_shape_matrix_tests.rs @@ -291,8 +291,8 @@ fn matrix_fill_range_revokes_leaving_a_genuinely_mixed_array() { /// The soundness-critical case: `splice` can replace elements **without /// changing `length`**, so the structural `verified_len` check cannot catch it. -/// Only splice's own `rebuild_array_layout` can. If that call is ever dropped, -/// this is the test that goes red — and nothing else would. +/// Only splice's own dense-move layout finisher can. If that call ever stops +/// revoking the proof, this is the test that goes red — and nothing else would. #[test] fn matrix_splice_equal_length_replacement_revokes() { let _serialized = test_serialize(); diff --git a/crates/perry-runtime/src/array/element_shape_tests.rs b/crates/perry-runtime/src/array/element_shape_tests.rs index 36adee7595..2fe5bc67e6 100644 --- a/crates/perry-runtime/src/array/element_shape_tests.rs +++ b/crates/perry-runtime/src/array/element_shape_tests.rs @@ -345,8 +345,8 @@ fn a_length_change_behind_the_runtimes_back_fails_the_proof_closed() { #[test] fn a_bulk_mutator_rebuild_clears_the_invariant() { let _serialized = test_serialize(); - // `shift`/`unshift`/`splice`/`fill`/`copyWithin`/`reverse`/`sort` all - // mutate slots with bare writes and then land in `rebuild_array_layout`. + // Bulk mutators that land in `rebuild_array_layout` must revoke the proof; + // splice/unshift have the same obligation through their dense-move helper. let arr = built_from_pushes(CLASS_A, 4); assert!(proof(arr).is_some()); unsafe { crate::array::header::rebuild_array_layout(arr) }; diff --git a/crates/perry-runtime/src/array/header_gc_slots.rs b/crates/perry-runtime/src/array/header_gc_slots.rs index d67434c628..2f32418676 100644 --- a/crates/perry-runtime/src/array/header_gc_slots.rs +++ b/crates/perry-runtime/src/array/header_gc_slots.rs @@ -6,6 +6,29 @@ use super::header::*; use super::ArrayHeader; +#[cfg(test)] +thread_local! { + static DENSE_MOVE_LAYOUT_CLASSIFIED_SLOTS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +pub(crate) fn test_reset_dense_move_layout_classified_slots() { + DENSE_MOVE_LAYOUT_CLASSIFIED_SLOTS.with(|count| count.set(0)); +} + +#[cfg(test)] +pub(crate) fn test_dense_move_layout_classified_slots() -> usize { + DENSE_MOVE_LAYOUT_CLASSIFIED_SLOTS.with(std::cell::Cell::get) +} + +#[inline] +fn note_layout_classified_slots(count: usize) { + #[cfg(test)] + DENSE_MOVE_LAYOUT_CLASSIFIED_SLOTS.with(|total| total.set(total.get() + count)); + #[cfg(not(test))] + let _ = count; +} + pub(crate) unsafe fn gc_element_slot_range( arr: *mut ArrayHeader, ) -> Option { @@ -187,6 +210,7 @@ pub(crate) unsafe fn rebuild_array_layout(arr: *mut ArrayHeader) { crate::gc::layout_mark_unknown(arr as *mut u8); return; } + note_layout_classified_slots(length); let was_all_pointer = super::header::array_object_flags_resolved(arr) & (crate::gc::GC_LAYOUT_STATE_MASK | crate::gc::GC_LAYOUT_ALL_POINTERS) == (crate::gc::GC_LAYOUT_SIDE_MASK | crate::gc::GC_LAYOUT_ALL_POINTERS); @@ -259,6 +283,89 @@ pub(crate) unsafe fn rebuild_array_layout(arr: *mut ArrayHeader) { } } +/// Settle GC metadata after a dense in-place element move without rebuilding +/// it from every live slot. +/// +/// `moved_src..moved_src + moved_count` was copied verbatim to `moved_dst` and +/// `inserted_start..inserted_start + inserted_count` contains the newly-written +/// values. A pointer-free layout remains exact when the inserted values are +/// pointer-free, and the header-only all-pointer proof remains exact when they +/// are all pointers. An index-specific mixed mask no longer names the moved +/// slots, so it is dropped to UNKNOWN in O(1). Element-shape evidence is always +/// revoked: inserted values can change KIND even when the array length does +/// not change (#7480). +/// +/// Survivor references are not new parent-child edges, but an old array's +/// dirty-page coverage follows their byte move. Translate that coverage +/// instead of replaying a write barrier for every survivor. This remains +/// page-only during incremental marking: the inserted-slot barriers below +/// perform all shading owed by the operation's genuinely new edges. +/// +/// # Safety +/// +/// `arr` is a live, forwarding-resolved ordinary Array. All ranges belong to +/// its inline allocation and no safepoint may occur between the move and this +/// call. The caller has already published the new logical length. +#[inline] +pub(crate) unsafe fn finish_array_dense_move_layout( + arr: *mut ArrayHeader, + moved_src: *const u64, + moved_dst: *mut u64, + moved_count: usize, + inserted_start: *mut u64, + inserted_count: usize, +) { + if arr.is_null() { + return; + } + + super::element_shape::clear_element_shape(arr); + let flags = super::header::array_object_flags_resolved(arr); + let layout = flags & (crate::gc::GC_LAYOUT_STATE_MASK | crate::gc::GC_LAYOUT_ALL_POINTERS); + let pointer_free = layout == crate::gc::GC_LAYOUT_POINTER_FREE; + let all_pointer = + layout == (crate::gc::GC_LAYOUT_SIDE_MASK | crate::gc::GC_LAYOUT_ALL_POINTERS); + + if moved_count != 0 && moved_src != moved_dst.cast_const() && !pointer_free { + let copied_bytes = moved_count * std::mem::size_of::(); + if !crate::gc::relocate_moved_old_object_dirty_pages( + arr as usize, + moved_src as usize, + moved_dst as usize, + copied_bytes, + ) { + crate::gc::replay_old_parent_slot_range_barriers(arr as usize, moved_dst, moved_count); + } + } + + note_layout_classified_slots(inserted_count); + let mut inserted_are_pointer_free = true; + let mut inserted_are_all_pointer = true; + let mut inserted_are_all_numeric = true; + for index in 0..inserted_count { + let slot = inserted_start.add(index); + let bits = *slot; + let pointer = crate::gc::layout_pointer_bearing_bits(bits); + inserted_are_pointer_free &= !pointer; + inserted_are_all_pointer &= pointer; + inserted_are_all_numeric &= value_bits_to_number(bits).is_some(); + crate::gc::runtime_write_barrier_slot(arr as usize, slot as usize, bits); + } + + if !inserted_are_all_numeric { + clear_array_numeric_layout(arr); + } + if (pointer_free && inserted_are_pointer_free) + || (all_pointer && inserted_are_all_pointer) + || (moved_count == 0 && inserted_count == 0) + { + return; + } + if flags & crate::gc::GC_LAYOUT_STATE_MASK != 0 { + crate::gc::layout_mark_unknown(arr.cast()); + } +} + #[inline] pub(crate) unsafe fn rebuild_array_layout_exact(arr: *mut ArrayHeader) { if arr.is_null() { diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index efe0dc67d7..e98d3efd54 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -54,6 +54,8 @@ mod callback_rooting_tests; #[cfg(test)] mod collection_tag_tests; #[cfg(test)] +mod dense_move_tests; +#[cfg(test)] mod forwarding_tests; #[cfg(test)] mod push_pop_tests; @@ -252,6 +254,8 @@ pub use self::search::{ pub use self::sort::{ js_array_sort_default, js_array_sort_with_comparator, js_validate_array_comparator, }; +#[cfg(test)] +pub(crate) use self::splice_slice::test_collect_after_splice_roots_once; pub use self::splice_slice::{ js_array_slice, js_array_slice_values, js_array_splice, js_array_splice_delete_count, }; @@ -267,14 +271,15 @@ pub(crate) use self::header::{ array_named_property_set, array_numeric_raw_f64_get, array_numeric_raw_f64_push_inbounds, array_numeric_raw_f64_set_inbounds, array_object_flags, array_object_flags_from_tag, array_object_flags_resolved, array_ptr_as_proxy, array_receiver_addr, array_receiver_gc_tag, - buffer_receiver_as_uint8_typed_array, 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, note_array_slot_resolved_flags, - rebuild_array_layout, rebuild_array_layout_exact, refresh_array_numeric_layout, - replay_array_growth_write_barriers, set_array_numeric_layout, store_array_slot, - store_array_slot_resolved, transfer_array_named_property_owner, transfer_array_numeric_layout, - typed_array_receiver, value_bits_to_number, NumericArrayLayout, MIN_ARRAY_CAPACITY, + buffer_receiver_as_uint8_typed_array, canonicalize_array_numeric_store_value_from_flags, + clean_arr_ptr, clean_arr_ptr_mut, clear_array_numeric_layout, clear_array_numeric_layout_ptr, + finish_array_dense_move_layout, 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, note_array_slot_resolved_flags, rebuild_array_layout, + rebuild_array_layout_exact, refresh_array_numeric_layout, replay_array_growth_write_barriers, + set_array_numeric_layout, store_array_slot, store_array_slot_resolved, + transfer_array_named_property_owner, transfer_array_numeric_layout, typed_array_receiver, + value_bits_to_number, NumericArrayLayout, MIN_ARRAY_CAPACITY, }; // Sole caller is the regex-engine-gated `regex::exec_array`, so the helper and diff --git a/crates/perry-runtime/src/array/push_pop.rs b/crates/perry-runtime/src/array/push_pop.rs index f5bb39c5bf..5e090e0032 100644 --- a/crates/perry-runtime/src/array/push_pop.rs +++ b/crates/perry-runtime/src/array/push_pop.rs @@ -1676,17 +1676,27 @@ pub extern "C" fn js_array_unshift_f64(arr: *mut ArrayHeader, value: f64) -> *mu } else { arr }; - let value = value_handle.get_nanbox_f64(); + let flags = array_object_flags_resolved(arr); + let value = + canonicalize_array_numeric_store_value_from_flags(flags, value_handle.get_nanbox_f64()); let elements_ptr = crate::array::array_elements_ptr(arr as *const ArrayHeader) as *mut f64; // Shift all elements up - // GC_STORE_AUDIT(BARRIERED): unshift memmove and new slot are followed by layout/barrier rebuild. + // GC_STORE_AUDIT(BARRIERED): the dense-move finisher translates + // survivor dirty pages and barriers the inserted slot below. ptr::copy(elements_ptr, elements_ptr.add(1), length as usize); // Write new element at beginning ptr::write(elements_ptr, value); (*arr).length = length + 1; - rebuild_array_layout(arr); + finish_array_dense_move_layout( + arr, + elements_ptr.cast(), + elements_ptr.add(1).cast(), + length as usize, + elements_ptr.cast(), + 1, + ); arr } } @@ -1738,42 +1748,55 @@ pub extern "C" fn js_array_unshift_variadic( return arr; } let scope = crate::gc::RuntimeHandleScope::new(); - let _arr_handle = scope.root_raw_mut_ptr(arr); - // Copy the items out before any grow can move arena memory; `items` - // points at a caller-owned alloca, so it is stable, but we read it - // before mutating to keep the logic simple. - let item_vec: Vec = unsafe { + let arr_handle = scope.root_raw_mut_ptr(arr); + // The caller-owned alloca itself is stable, but a copying collection can + // move any pointer values stored in it without rewriting those raw words. + // Give every item a mutable runtime root before growth can allocate. + let item_handles = unsafe { if items.is_null() { Vec::new() } else { - std::slice::from_raw_parts(items, count as usize).to_vec() + scope.root_nanbox_f64_slice(std::slice::from_raw_parts(items, count as usize)) } }; - let n = item_vec.len(); - unsafe { - let length = (*arr).length; - let capacity = (*arr).capacity; - let arr = if length + n as u32 > capacity { - js_array_grow(arr, length + n as u32) - } else { - arr - }; + let n = item_handles.len(); + let (length, capacity) = arr_handle.with_mut_ptr::(|current| unsafe { + ((*current).length, (*current).capacity) + }); + if length + n as u32 > capacity { + let grown = arr_handle + .with_mut_ptr::(|current| js_array_grow(current, length + n as u32)); + arr_handle.set_raw_mut_ptr(grown); + } + arr_handle.with_mut_ptr::(|arr| unsafe { + let flags = array_object_flags_resolved(arr); let elements_ptr = crate::array::array_elements_ptr(arr as *const ArrayHeader) as *mut f64; // Shift existing elements up by `n`. - // GC_STORE_AUDIT(BARRIERED): memmove + new slots followed by layout/barrier rebuild. + // GC_STORE_AUDIT(BARRIERED): the dense-move finisher translates + // survivor dirty pages and barriers the inserted slots below. ptr::copy(elements_ptr, elements_ptr.add(n), length as usize); // Write items in source order at the front. #5552: demote each // uniquely-owned string before it aliases its slot (no-op for SSO / // non-string). - for (i, v) in item_vec.into_iter().enumerate() { + for (i, value) in item_handles.iter().enumerate() { + let v = value.get_nanbox_f64(); crate::string::js_string_addref_if_heap_string(v); - // GC_STORE_AUDIT(BARRIERED): inserted slots are followed by the layout/barrier rebuild below. + let v = canonicalize_array_numeric_store_value_from_flags(flags, v); + // GC_STORE_AUDIT(BARRIERED): inserted slots are covered by the + // dense-move finisher below. ptr::write(elements_ptr.add(i), v); } (*arr).length = length + n as u32; - rebuild_array_layout(arr); + finish_array_dense_move_layout( + arr, + elements_ptr.cast(), + elements_ptr.add(n).cast(), + length as usize, + elements_ptr.cast(), + n, + ); arr - } + }) } fn unshift_array_spec_path(arr: *mut ArrayHeader, items: &[f64]) -> *mut ArrayHeader { diff --git a/crates/perry-runtime/src/array/splice_slice.rs b/crates/perry-runtime/src/array/splice_slice.rs index d2792708fa..48611a9477 100644 --- a/crates/perry-runtime/src/array/splice_slice.rs +++ b/crates/perry-runtime/src/array/splice_slice.rs @@ -2,6 +2,16 @@ use super::*; use std::ptr; +#[cfg(test)] +thread_local! { + static SPLICE_COLLECT_AFTER_ROOTING_ONCE: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +#[cfg(test)] +pub(crate) fn test_collect_after_splice_roots_once() { + SPLICE_COLLECT_AFTER_ROOTING_ONCE.with(|armed| armed.set(true)); +} + /// Splice an array - removes elements and optionally inserts new ones /// start: starting index (can be negative for from-end) /// delete_count: number of elements to delete @@ -70,17 +80,38 @@ pub extern "C" fn js_array_splice( (delete_count as u32).min(len as u32 - start_idx) }; + let scope = crate::gc::RuntimeHandleScope::new(); + let arr_handle = scope.root_raw_mut_ptr(arr); + // `items` points at caller-owned raw storage. The storage address is + // stable, but an evacuating collection cannot rewrite pointer values + // inside it, so root every value before species creation can allocate + // or invoke user code. + let item_handles = if items.is_null() { + Vec::new() + } else { + scope.root_nanbox_f64_slice(std::slice::from_raw_parts(items, items_count as usize)) + }; + #[cfg(test)] + SPLICE_COLLECT_AFTER_ROOTING_ONCE.with(|armed| { + if armed.replace(false) { + crate::gc::gc_collect_minor(); + } + }); + // Create array of deleted elements via ArraySpeciesCreate (ECMA-262 // §23.1.3.31 step 11): reads `O.constructor` / `@@species` and throws // on a poisoned getter or non-constructor species before the receiver // is mutated. - let recv_value = f64::from_bits(crate::value::JSValue::pointer(arr as *const u8).bits()); + let recv_value = arr_handle.with_mut_ptr::(|arr| { + f64::from_bits(crate::value::JSValue::pointer(arr as *const u8).bits()) + }); let deleted_box = crate::array::species::array_species_create(recv_value, actual_delete as usize); - let deleted_is_plain = crate::array::species::species_result_is_plain_array(deleted_box); - let deleted = crate::value::js_nanbox_get_pointer(deleted_box) as *mut ArrayHeader; - - let elements_ptr = crate::array::array_elements_ptr(arr as *const ArrayHeader) as *mut f64; + let deleted_handle = scope.root_nanbox_f64(deleted_box); + let deleted_is_plain = + crate::array::species::species_result_is_plain_array(deleted_handle.get_nanbox_f64()); + let removed_value_handle = + scope.root_nanbox_f64(f64::from_bits(crate::value::TAG_UNDEFINED)); // Copy deleted elements to return array. ECMA-262 §23.1.3.31 step // 12.b: each removed index goes through HasProperty/Get — a hole @@ -88,38 +119,62 @@ pub extern "C" fn js_array_splice( // property of the deleted array (test262 splice/S15.4.4.12_A4_T3); // a genuinely absent index stays a hole. let spec_read = |i: usize| -> f64 { - let v = *elements_ptr.add(start_idx as usize + i); + let v = arr_handle.with_mut_ptr::(|arr| { + let elements_ptr = + crate::array::array_elements_ptr(arr as *const ArrayHeader) as *const f64; + *elements_ptr.add(start_idx as usize + i) + }); if v.to_bits() == crate::value::TAG_HOLE { let idx = start_idx + i as u32; - if crate::array::array_spec_has_index(arr, idx) { - return crate::array::array_spec_get(arr, idx); + if arr_handle.with_mut_ptr::(|arr| { + crate::array::array_spec_has_index(arr, idx) + }) { + return arr_handle.with_mut_ptr::(|arr| { + crate::array::array_spec_get(arr, idx) + }); } } v }; if deleted_is_plain { + let deleted = crate::value::js_nanbox_get_pointer(deleted_handle.get_nanbox_f64()) + as *mut ArrayHeader; (*deleted).length = actual_delete; - let deleted_elements = - crate::array::array_elements_ptr(deleted as *const ArrayHeader) as *mut f64; // Hole reads also consult recorded custom prototypes, which are // not covered by array_iteration_is_exotic's canonical-proto flags. - let src_exotic = crate::array::array_iteration_is_exotic(arr) + let src_exotic = arr_handle + .with_mut_ptr::(|arr| crate::array::array_iteration_is_exotic(arr)) || crate::object::prototype_chain::array_static_proto_recorded(); for i in 0..actual_delete as usize { let value = spec_read(i); if src_exotic { // Publish before the next getter can collect or throw, // leaving the species result reachable with a partial copy. + let deleted = + crate::value::js_nanbox_get_pointer(deleted_handle.get_nanbox_f64()) + as *mut ArrayHeader; note_array_slot(deleted, i, value.to_bits()); } else { // GC_STORE_AUDIT(BARRIERED): no source callbacks; layout/barrier rebuild follows the copy. + let deleted = + crate::value::js_nanbox_get_pointer(deleted_handle.get_nanbox_f64()) + as *mut ArrayHeader; + let deleted_elements = + crate::array::array_elements_ptr(deleted as *const ArrayHeader) as *mut f64; ptr::write(deleted_elements.add(i), value); } } + let deleted = crate::value::js_nanbox_get_pointer(deleted_handle.get_nanbox_f64()) + as *mut ArrayHeader; rebuild_array_layout(deleted); } else { for i in 0..actual_delete as usize { - crate::array::species::species_result_set(deleted_box, i, spec_read(i)); + removed_value_handle.set_nanbox_f64(spec_read(i)); + crate::array::species::species_result_set( + deleted_handle.get_nanbox_f64(), + i, + removed_value_handle.get_nanbox_f64(), + ); } } @@ -127,49 +182,70 @@ pub extern "C" fn js_array_splice( let new_len = len as u32 - actual_delete + items_count; // Grow array if needed - let arr = if new_len > (*arr).capacity { - js_array_grow(arr, new_len) - } else { - arr - }; - let elements_ptr = crate::array::array_elements_ptr(arr as *const ArrayHeader) as *mut f64; + let should_grow = + arr_handle.with_mut_ptr::(|current| new_len > (*current).capacity); + if should_grow { + let grown = arr_handle + .with_mut_ptr::(|current| js_array_grow(current, new_len)); + arr_handle.set_raw_mut_ptr(grown); + } + arr_handle.with_mut_ptr::(|arr| { + let flags = array_object_flags_resolved(arr); + let elements_ptr = + crate::array::array_elements_ptr(arr as *const ArrayHeader) as *mut f64; - // Shift elements after the splice point - let tail_start = start_idx + actual_delete; - let tail_len = len as u32 - tail_start; + // Shift elements after the splice point + let tail_start = start_idx + actual_delete; + let tail_len = len as u32 - tail_start; - if items_count != actual_delete && tail_len > 0 { - // Need to shift the tail - let src = elements_ptr.add(tail_start as usize); - let dst = elements_ptr.add((start_idx + items_count) as usize); - // GC_STORE_AUDIT(BARRIERED): splice tail memmove is followed by layout/barrier rebuild. - ptr::copy(src, dst, tail_len as usize); - } + if items_count != actual_delete && tail_len > 0 { + // Need to shift the tail + let src = elements_ptr.add(tail_start as usize); + let dst = elements_ptr.add((start_idx + items_count) as usize); + // GC_STORE_AUDIT(BARRIERED): the dense-move finisher translates + // survivor dirty pages below. + ptr::copy(src, dst, tail_len as usize); + } - // Insert new items - if items_count > 0 && !items.is_null() { - for i in 0..items_count as usize { - let item = *items.add(i); - // A uniquely-owned string spliced in now aliases the array slot — - // demote it to shared so a later `s += x` doesn't mutate it in - // place. No-op for SSO / non-string. (This insert path doesn't - // funnel through `note_array_slot`.) - crate::string::js_string_addref_if_heap_string(item); - // GC_STORE_AUDIT(BARRIERED): splice inserted item writes are followed by layout/barrier rebuild. - ptr::write(elements_ptr.add(start_idx as usize + i), item); + // Insert new items + if items_count > 0 && !item_handles.is_empty() { + for (i, item_handle) in item_handles.iter().enumerate() { + let item = item_handle.get_nanbox_f64(); + // A uniquely-owned string spliced in now aliases the array slot — + // demote it to shared so a later `s += x` doesn't mutate it in + // place. No-op for SSO / non-string. (This insert path doesn't + // funnel through `note_array_slot`.) + crate::string::js_string_addref_if_heap_string(item); + let item = canonicalize_array_numeric_store_value_from_flags(flags, item); + // GC_STORE_AUDIT(BARRIERED): inserted items are covered by the + // dense-move finisher below. + ptr::write(elements_ptr.add(start_idx as usize + i), item); + } } - } - // ECMA-262 §23.1.3.31 step 24: Set(O, "length", …, true) — throws on a - // non-writable `length` (test262 splice/S15.4.4.12_A6.1_T2/T3). - super::push_pop::guard_writable_length(arr); - (*arr).length = new_len; - rebuild_array_layout(arr); + // ECMA-262 §23.1.3.31 step 24: Set(O, "length", …, true) — throws on a + // non-writable `length` (test262 splice/S15.4.4.12_A6.1_T2/T3). + super::push_pop::guard_writable_length(arr); + (*arr).length = new_len; + let moved_count = if items_count != actual_delete { + tail_len as usize + } else { + 0 + }; + finish_array_dense_move_layout( + arr, + elements_ptr.add(tail_start as usize).cast(), + elements_ptr.add((start_idx + items_count) as usize).cast(), + moved_count, + elements_ptr.add(start_idx as usize).cast(), + items_count as usize, + ); - // Return modified array via out param - *out_arr = arr; + // Return modified array via out param + *out_arr = arr; + }); - deleted + crate::value::js_nanbox_get_pointer(deleted_handle.get_nanbox_f64()) as *mut ArrayHeader } } diff --git a/crates/perry-runtime/src/gc/barrier/mod.rs b/crates/perry-runtime/src/gc/barrier/mod.rs index 52b824dda2..7a78753ca2 100644 --- a/crates/perry-runtime/src/gc/barrier/mod.rs +++ b/crates/perry-runtime/src/gc/barrier/mod.rs @@ -1472,23 +1472,41 @@ pub(super) fn replay_old_parent_slot_range(parent_addr: usize, slots: *mut u64, /// a `u64` slot run — and it is the SAME invariant the minor collector already /// trusts every cycle, not a new assumption. /// -/// Returns `false` when it declines (an incremental cycle is live, so the -/// values also owe SATB shading), and the caller must fall back to the full -/// value-derived replay. +/// Returns `false` when an incremental cycle requires the copied values to be +/// shaded or the source has no old-parent coverage to donate. pub(crate) fn relocate_copied_old_object_dirty_pages( new_parent_addr: usize, old_base: usize, new_base: usize, copied_bytes: usize, ) -> bool { - if copied_bytes == 0 { - return true; - } - // Shading is about values an in-progress mark may not have seen; a page is - // not an answer to it. Hand those cycles back to the full replay. if !incremental_mark_barrier_globally_idle() { return false; } + relocate_old_object_dirty_pages(new_parent_addr, old_base, new_base, copied_bytes) +} + +/// Translate dirty pages for a no-safepoint slot move within one parent. The +/// parent-child edge set survives unchanged, so incremental marking owes no +/// survivor shading; callers separately barrier genuinely inserted values. +pub(crate) fn relocate_moved_old_object_dirty_pages( + parent_addr: usize, + old_base: usize, + new_base: usize, + copied_bytes: usize, +) -> bool { + relocate_old_object_dirty_pages(parent_addr, old_base, new_base, copied_bytes) +} + +fn relocate_old_object_dirty_pages( + new_parent_addr: usize, + old_base: usize, + new_base: usize, + copied_bytes: usize, +) -> bool { + if copied_bytes == 0 { + return true; + } if !write_barriers_enabled() || !barrier_remembering_active() { return true; } diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index 2882f4f75e..2fb98f95da 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -445,7 +445,7 @@ pub(super) fn strip_nanbox_user_ptr(bits: u64) -> usize { } #[inline] -pub(in crate::gc) fn layout_pointer_bearing_bits(bits: u64) -> bool { +pub(crate) fn layout_pointer_bearing_bits(bits: u64) -> bool { let tag = bits & TAG_MASK; if tag == POINTER_TAG || tag == STRING_TAG || tag == BIGINT_TAG { return bits & POINTER_MASK != 0; diff --git a/crates/perry-runtime/src/gc/tests/barrier.rs b/crates/perry-runtime/src/gc/tests/barrier.rs index 457b90a7f5..4f200a8332 100644 --- a/crates/perry-runtime/src/gc/tests/barrier.rs +++ b/crates/perry-runtime/src/gc/tests/barrier.rs @@ -1948,3 +1948,29 @@ fn dirty_page_translation_only_inherits_from_an_old_gen_source() { the invariant for; refusing it would make the whole translation dead" ); } + +#[test] +fn in_place_dirty_page_translation_stays_bounded_during_incremental_marking() { + let _guard = GcTestIsolationGuard::new(); + reset_remembered_set(); + let parent = crate::arena::arena_alloc_gc_old(24 * 1024, 8, GC_TYPE_ARRAY) as usize; + let source = parent + 1024; + let destination = source + 8192; + let source_page = crate::arena::generation_page_for_addr(source); + let destination_page = crate::arena::generation_page_for_addr(destination); + assert_ne!(source_page, destination_page); + assert!(super::super::barrier::mark_dirty_old_page(source_page)); + assert!(!old_page_dirty_for(destination_page)); + + let valid_ptrs = build_valid_pointer_set(); + let _active = IncrementalMarkBarrierTestGuard::new(&valid_ptrs); + assert!(!incremental_mark_barrier_globally_idle()); + assert!(relocate_moved_old_object_dirty_pages( + parent, + source, + destination, + std::mem::size_of::(), + )); + assert!(old_page_dirty_for(destination_page)); + reset_remembered_set(); +} diff --git a/crates/perry-runtime/src/gc/tests/copying.rs b/crates/perry-runtime/src/gc/tests/copying.rs index dbbab1bb9e..8f7317b2d4 100644 --- a/crates/perry-runtime/src/gc/tests/copying.rs +++ b/crates/perry-runtime/src/gc/tests/copying.rs @@ -7,6 +7,7 @@ mod pointer_publish_7154; mod promise_side_tables; mod promoted_remembered_7803; mod shift_queue; +mod splice_unshift; mod survival_and_malloc; mod verify_malloc_borrow; mod verify_parent_context; diff --git a/crates/perry-runtime/src/gc/tests/copying/splice_unshift.rs b/crates/perry-runtime/src/gc/tests/copying/splice_unshift.rs new file mode 100644 index 0000000000..dcd5761a27 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/copying/splice_unshift.rs @@ -0,0 +1,195 @@ +//! Moving-collector witnesses for splice/unshift dense slot moves (#10087). +use super::*; +use crate::array::{self, ArrayHeader}; + +#[test] +fn promoted_array_keeps_new_unshift_and_splice_children_through_minor_and_full_gc() { + let _guard = CopyingNurseryTestGuard::new(1); + let _tenuring = + crate::gc::tenuring::set_survivals_for_test(crate::gc::tenuring::GC_TENURING_SURVIVALS_MAX); + let mut arr = array::js_array_alloc(16); + for value in [1.0, 2.0, 3.0, 4.0] { + arr = array::js_array_push_f64(arr, value); + } + js_shadow_slot_set(0, ptr_bits(arr as usize)); + + for _ in 0..crate::gc::tenuring::GC_TENURING_SURVIVALS_MAX { + let _ = gc_collect_minor(); + arr = (js_shadow_slot_get(0) & POINTER_MASK) as *mut ArrayHeader; + } + assert!( + crate::arena::pointer_in_old_gen(arr as usize), + "the fixture array must actually be promoted before its stores" + ); + + let first = young_leaf(); + arr = array::js_array_unshift_f64(arr, f64::from_bits(ptr_bits(first))); + js_shadow_slot_set(0, ptr_bits(arr as usize)); + let second = young_leaf(); + let items = [f64::from_bits(ptr_bits(second))]; + let mut out = arr; + array::js_array_splice(arr, 3, 0, items.as_ptr(), 1, &mut out); + arr = out; + js_shadow_slot_set(0, ptr_bits(arr as usize)); + + let trace = collect_minor_trace(GcTriggerKind::Direct); + assert_copied_minor_trace(&trace, true, CopiedMinorFallbackReason::None, false); + let first_after = (array::js_array_get_f64(arr, 0).to_bits() & POINTER_MASK) as usize; + let second_after = (array::js_array_get_f64(arr, 3).to_bits() & POINTER_MASK) as usize; + assert_ne!(first_after, first); + assert_ne!(second_after, second); + assert!(crate::arena::pointer_in_nursery(first_after)); + assert!(crate::arena::pointer_in_nursery(second_after)); + + gc_collect_full_mark_sweep_with_trigger(GcTriggerSnapshot::capture(GcTriggerKind::Direct)); + for child in [ + array::js_array_get_f64(arr, 0).to_bits(), + array::js_array_get_f64(arr, 3).to_bits(), + ] { + let child = (child & POINTER_MASK) as *const u8; + unsafe { + assert_ne!((*header_from_user_ptr(child)).size, 0); + } + } + js_shadow_slot_set(0, crate::value::TAG_UNDEFINED); +} + +#[test] +fn old_array_unshift_translates_a_young_edge_across_a_page_boundary() { + let _guard = CopyingNurseryTestGuard::new(1); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let arr = array::js_array_alloc(OLD_BORN_ELEMENTS); + assert!(crate::arena::pointer_in_old_gen(arr as usize)); + js_shadow_slot_set(0, ptr_bits(arr as usize)); + + let slots = unsafe { array::array_elements_ptr(arr) }; + let source_index = (0..OLD_BORN_ELEMENTS as usize - 1) + .find(|&index| { + crate::arena::generation_page_for_addr(unsafe { slots.add(index) } as usize) + != crate::arena::generation_page_for_addr(unsafe { slots.add(index + 1) } as usize) + }) + .expect("old-born array must span an element page boundary"); + for index in 0..source_index { + assert_eq!(array::js_array_push_f64(arr, index as f64), arr); + } + let child = young_leaf(); + assert_eq!( + array::js_array_push_f64(arr, f64::from_bits(ptr_bits(child))), + arr + ); + assert_eq!(array::js_array_length(arr) as usize, source_index + 1); + + assert_eq!(array::js_array_unshift_f64(arr, -1.0), arr); + let destination = unsafe { slots.add(source_index + 1) }; + assert_ne!( + crate::arena::generation_page_for_addr(unsafe { destination.sub(1) } as usize), + crate::arena::generation_page_for_addr(destination as usize), + "the moved child must cross into a different remembered-set page" + ); + + let trace = collect_minor_trace(GcTriggerKind::Direct); + assert_copied_minor_trace(&trace, true, CopiedMinorFallbackReason::None, false); + let child_after = + (array::js_array_get_f64(arr, (source_index + 1) as u32).to_bits() & POINTER_MASK) as usize; + assert_ne!(child_after, child); + assert!(crate::arena::pointer_in_nursery(child_after)); + js_shadow_slot_set(0, crate::value::TAG_UNDEFINED); +} + +#[test] +fn splice_roots_receiver_and_inserted_pointer_values_across_species_allocation() { + let _guard = CopyingNurseryTestGuard::new(3); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + register_runtime_handle_root_scanner_for_tests(); + let mut arr = array::js_array_alloc(4); + arr = array::js_array_push_f64(arr, 17.0); + let first = young_leaf(); + let second = young_leaf(); + assert!(crate::arena::pointer_in_nursery(arr as usize)); + assert!(crate::arena::pointer_in_nursery(first)); + assert!(crate::arena::pointer_in_nursery(second)); + js_shadow_slot_set(0, ptr_bits(arr as usize)); + js_shadow_slot_set(1, ptr_bits(first)); + js_shadow_slot_set(2, ptr_bits(second)); + + // The raw `items` alloca below is deliberately not a root. Force a moving + // minor at the exact point where splice has promised to establish its own + // mutable handles and before species creation performs an allocation. + crate::array::test_collect_after_splice_roots_once(); + let collections_before = gc_collection_count(); + let items = [ + f64::from_bits(ptr_bits(first)), + f64::from_bits(ptr_bits(second)), + ]; + let mut out = arr; + let deleted = array::js_array_splice(arr, 1, 0, items.as_ptr(), 2, &mut out); + + assert!(gc_collection_count() > collections_before); + let rooted_arr = (js_shadow_slot_get(0) & POINTER_MASK) as *mut ArrayHeader; + let first_after = (js_shadow_slot_get(1) & POINTER_MASK) as usize; + let second_after = (js_shadow_slot_get(2) & POINTER_MASK) as usize; + assert_ne!(rooted_arr, arr, "the receiver fixture must move"); + assert_eq!(out, rooted_arr, "splice must return the rewritten receiver"); + assert_ne!(first_after, first, "the first inserted fixture must move"); + assert_ne!( + second_after, second, + "the second inserted fixture must move" + ); + assert_eq!( + array::js_array_get_f64(out, 1).to_bits() & POINTER_MASK, + first_after as u64 + ); + assert_eq!( + array::js_array_get_f64(out, 2).to_bits() & POINTER_MASK, + second_after as u64 + ); + assert_eq!(array::js_array_length(deleted), 0); + for slot in 0..3 { + js_shadow_slot_set(slot, crate::value::TAG_UNDEFINED); + } +} + +#[test] +fn old_array_splice_translates_a_young_edge_left_across_a_page_boundary() { + let _guard = CopyingNurseryTestGuard::new(1); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let arr = array::js_array_alloc(OLD_BORN_ELEMENTS); + assert!(crate::arena::pointer_in_old_gen(arr as usize)); + js_shadow_slot_set(0, ptr_bits(arr as usize)); + + let slots = unsafe { array::array_elements_ptr(arr) }; + let source_index = (1..OLD_BORN_ELEMENTS as usize) + .find(|&index| { + crate::arena::generation_page_for_addr(unsafe { slots.add(index - 1) } as usize) + != crate::arena::generation_page_for_addr(unsafe { slots.add(index) } as usize) + }) + .expect("old-born array must span an element page boundary"); + for index in 0..source_index { + assert_eq!(array::js_array_push_f64(arr, index as f64), arr); + } + let child = young_leaf(); + assert_eq!( + array::js_array_push_f64(arr, f64::from_bits(ptr_bits(child))), + arr + ); + assert_eq!(array::js_array_length(arr) as usize, source_index + 1); + + let mut out = arr; + let deleted = array::js_array_splice(arr, 0, 1, std::ptr::null(), 0, &mut out); + assert_eq!(out, arr); + assert_eq!(array::js_array_length(deleted), 1); + let destination = unsafe { slots.add(source_index - 1) }; + assert_ne!( + crate::arena::generation_page_for_addr(destination as usize), + crate::arena::generation_page_for_addr(unsafe { destination.add(1) } as usize), + "the moved child must cross into a different remembered-set page" + ); + + let trace = collect_minor_trace(GcTriggerKind::Direct); + assert_copied_minor_trace(&trace, true, CopiedMinorFallbackReason::None, false); + let child_after = + (array::js_array_get_f64(arr, (source_index - 1) as u32).to_bits() & POINTER_MASK) as usize; + assert_ne!(child_after, child); + assert!(crate::arena::pointer_in_nursery(child_after)); + js_shadow_slot_set(0, crate::value::TAG_UNDEFINED); +} diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 75e447ed80..fa68dfa74e 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -148,6 +148,18 @@ "verdict": "not_a_gc_pointer", "why": "#9794 allocation-site sampling: bytes remaining until the next sample. A `Cell` countdown, decremented per allocation and reset on fire — a quantity, never an address." }, + { + "file": "crates/perry-runtime/src/array/header_gc_slots.rs", + "name": "DENSE_MOVE_LAYOUT_CLASSIFIED_SLOTS", + "verdict": "test_only", + "why": "#[cfg(test)] Cell counter for asserting bounded dense-move layout classification work. It stores only a slot count and is absent from shipped binaries." + }, + { + "file": "crates/perry-runtime/src/array/splice_slice.rs", + "name": "SPLICE_COLLECT_AFTER_ROOTING_ONCE", + "verdict": "test_only", + "why": "#[cfg(test)] Cell one-shot flag that forces a collection after splice roots its receiver and inserted values. It stores only true/false and is absent from shipped binaries." + }, { "file": "crates/perry-runtime/src/async_hooks.rs", "name": "ASYNC_HOOK_HANDLES", diff --git a/scripts/gc_store_site_inventory.py b/scripts/gc_store_site_inventory.py index 17071c5638..a61fe6a6e3 100644 --- a/scripts/gc_store_site_inventory.py +++ b/scripts/gc_store_site_inventory.py @@ -673,6 +673,9 @@ def scan_file(path: Path) -> list[Finding]: "replay_array_growth_write_barriers": ( "array/header.rs: replays the copied prefix's barriers after js_array_grow" ), + "finish_array_dense_move_layout": ( + "array/header_gc_slots.rs: translates moved dirty pages and barriers inserted slots" + ), "store_object_field_slot": "object/mod.rs: object field store via runtime_store", "store_object_field_slot_layout_deferred": ( "object/mod.rs: JSON-parser field store; layout settled at finalize (#7630)"