diff --git a/benchmarks/array-sort/README.md b/benchmarks/array-sort/README.md new file mode 100644 index 0000000000..18194840f6 --- /dev/null +++ b/benchmarks/array-sort/README.md @@ -0,0 +1,71 @@ +# Generic comparator sort benchmark + +The harness feeds identical inputs to Node and matching baseline/candidate Perry compiler/runtime builds. Every output element, value type, and stable object order is checked against Node. JSON loading, array construction, verification, and serialization are outside the sort timer; comparator construction is included. + +```sh +python3 benchmarks/array-sort/run.py --baseline /path/to/baseline-build \ + --candidate /path/to/candidate-build --samples 9 --warmups 2 \ + --output /tmp/array-sort-results +``` + +Each build directory must contain its matching `perry`, `libperry_runtime.a`, and `libperry_stdlib.a`. Build both revisions with the same flags and package set; keep runtime coherence checks enabled. Options include `--size`, `--samples`, `--warmups`, `--node`, and `--cases all|matrix|issue`. + +`issue-289.ts` reproduces the original negative-number input from [scriptc #289](https://github.com/vercel-labs/scriptc/issues/289), including first-sort initialization, and verifies every element. It is distinct from the matrix's positive descending fixture and runs by default. + +## M1 Max results, 2026-09-11 + +Baseline: `1a9c0de6cb790d2467b0ca22a660870025179b37` (Perry 0.5.1531). Candidate: this PR's compiler/runtime optimizations. Node v26.5.1. Both Perry builds use release optimization, 16 codegen units, and LTO off. All rows contain 100,000 elements; medians of nine randomized interleaved fresh-process samples after two warmups per engine. Other user workloads were active on this shared host: retain the raw sample ranges when interpreting close results. + +The original issue measures **90.97 → 0.61 ms**, compared with **Node 1.68 ms** (2.75× faster than Node). Perry beats Node in **24/25 cases** in this run. This is a local benchmark result, not a claim that all JavaScript runs faster than Node. + +| Input | Values | Baseline ms | Perry ms | Node ms | Node / Perry | +| --- | --- | ---: | ---: | ---: | ---: | +| random | number | 75.02 | 15.86 | 17.96 | 1.13× | +| random | object | 104.57 | 24.03 | 21.62 | 0.90× | +| random | string | 241.75 | 28.55 | 37.68 | 1.32× | +| sorted | number | 30.27 | 0.62 | 1.24 | 2.00× | +| sorted | object | 38.08 | 0.88 | 1.31 | 1.49× | +| sorted | string | 91.19 | 1.33 | 2.86 | 2.15× | +| reverse | number | 87.94 | 0.63 | 1.33 | 2.10× | +| reverse | object | 121.69 | 0.90 | 1.38 | 1.53× | +| reverse | string | 275.85 | 1.31 | 1.80 | 1.37× | +| equal | number | 29.60 | 0.58 | 1.19 | 2.05× | +| equal | object | 37.71 | 0.85 | 1.27 | 1.50× | +| equal | string | 90.22 | 1.87 | 1.99 | 1.06× | +| duplicates | number | 60.55 | 6.49 | 9.59 | 1.48× | +| duplicates | object | 83.01 | 9.56 | 10.76 | 1.13× | +| duplicates | string | 226.06 | 12.45 | 14.63 | 1.17× | +| runs | number | 34.48 | 0.86 | 2.53 | 2.94× | +| runs | object | 45.64 | 1.23 | 2.60 | 2.12× | +| runs | string | 100.67 | 1.36 | 3.67 | 2.70× | +| nearly_sorted | number | 32.14 | 1.07 | 2.66 | 2.49× | +| nearly_sorted | object | 43.93 | 1.58 | 2.89 | 1.83× | +| nearly_sorted | string | 99.56 | 1.88 | 4.22 | 2.24× | +| organ_pipe | number | 58.71 | 0.89 | 2.09 | 2.35× | +| organ_pipe | object | 79.58 | 1.45 | 2.15 | 1.48× | +| organ_pipe | string | 172.57 | 1.96 | 3.19 | 1.63× | +| issue_289 | number | 90.97 | 0.61 | 1.68 | 2.75× | + +Ratios above 1 favor Perry. Random objects remain 11% slower than Node. A separate 15-sample interleaved comparison against the preceding PR build retains this gap and records modest gains from removing full-cache loads; those raw samples are included as well. [measured-m1-max.json](measured-m1-max.json) contains every raw timing, executable/archive hashes, source hashes, the harness hash, and build flags. Local build paths are replaced with arm names. + +## What changed + +The former comparator sort insertion-sorted fixed 32-element chunks and merged values through rooted arrays. Profiles showed excessive comparisons on ordered input and repeated array-layout, slot-tracking, and write-barrier work inside comparison loops. + +The adaptive engine sorts integer indices into an immutable rooted snapshot. It detects natural runs, reverses only strictly descending runs, extends short runs with stable binary insertion, balances merges, and searches blocks after consecutive wins. Values are published once; the dense path consumes the permutation directly. Exotic receivers retain property operations, strict writes/deletes, and current roots across getters, setters, and callbacks. + +Native stack root cells are bound to the moving collector and reread before each callback. Small index workspaces use the stack; larger workspaces use rooted, non-moving Uint32Array backing storage, which remains reclaimable when a JavaScript exception skips Rust destructors. Array numeric-layout reconstruction scans once and rebuilds remembered edges in bulk after callback-free copies. + +Shared compiler/runtime improvements reduce dynamic numeric-tag checks, primitive-string coercion, short ASCII comparison work, and closure dispatch. Generic property reads use an atomic compact ShapeId/slot MRU with live receiver-kind and descriptor guards; the full cache pointer is loaded only after this compact path fails, and polymorphic, overflow, proxy, and prototype handling remains available on misses. This adds eight bytes per inline property-read site. Cache-miss and marking-barrier calls carry a code-layout hint while retaining their full memory/GC effects. + +All optimizations apply to ordinary operations and arbitrary comparators. The compiler does not recognize sort comparator bodies, substitute extracted keys, skip output checks, or defer collections. Non-ASCII comparisons retain UTF-16 ordering, and comparator results retain abstract ToNumber semantics, including BigInt/Symbol errors. + +## Validation + +- The complete serialized suites pass: 1,465 compiler tests and 3,537 runtime tests (five ignored). +- Runtime witnesses force actual movement during comparisons, stack-root buffer growth, and collection getters. The new getter regression failed with wrong output before the rooting fix and passes after it, asserting relocation of both closure and receiver. +- Four compiled TypeScript regressions match Node normally and with seeded copying GC, evacuation verification, and protected from-space. Coverage includes mixed numeric tags, strings across word boundaries and UTF-16 cases, changing property-cache shapes, overflow slots, descriptors, proxies, stable identities, holes, array-like receivers, `toSorted`, allocating coercion/setters, mutation, reentrancy, and exceptions. +- All 25 benchmark cases verify complete output against Node on every sample. +- Address classification, GC store inventory, root-holder/poll-reach checks, raw-handle debt, file-size, formatting, and shape-descriptor census gates pass. The census follows both cache entry points into their shared implementation and includes sabotage tests for bypassed authority and invalid ShapeIds. These source audits complement the runtime witnesses; source markers alone are not proof of moving-GC correctness. + +Linux CI also passed the actual-movement sort/root-cell witnesses. CI on the preceding head reported a stale shape-census assumption; this follow-up updates that checker and its sabotage tests. Other failures remain: Locally reproduced gap cases have the same outcomes on pristine baseline and earlier follow-up candidates. The public benchmark freshness check also fails on baseline; the Linux stack-size assertion requires Linux verification. See the PR for current CI status rather than interpreting local checks as a green CI run. diff --git a/benchmarks/array-sort/bench.ts b/benchmarks/array-sort/bench.ts new file mode 100644 index 0000000000..3a37b502df --- /dev/null +++ b/benchmarks/array-sort/bench.ts @@ -0,0 +1,35 @@ +import { readFileSync } from "node:fs"; +const distribution = process.argv[2] || "random"; +const kind = process.argv[3] || "number"; +const n = Number(process.argv[4] || "100000"); +const rounds = Number(process.argv[5] || "1"); +for (let round = 0; round < rounds; round++) { + const values: number[] = JSON.parse(readFileSync(process.argv[6], "utf8")); + const items: any[] = []; + for (let i = 0; i < n; i++) { + if (kind === "object") items.push({key: values[i], id: i}); + else if (kind === "string") items.push(("0000000000" + values[i]).slice(-10)); + else items.push(values[i]); + } + const start = performance.now(); + if (kind === "object") items.sort((a, b) => a.key - b.key); + else if (kind === "string") items.sort((a, b) => a < b ? -1 : a > b ? 1 : 0); + else items.sort((a, b) => a - b); + const sortMs = performance.now() - start; + let hash = 1; + let sum = 0; + for (let i = 0; i < n; i++) { + const value = kind === "object" ? items[i].key : Number(items[i]); + if (i > 0) { + const previous = kind === "object" ? items[i - 1].key : Number(items[i - 1]); + if (previous > value) throw new Error("out of order at " + i); + if (kind === "object" && previous === value && items[i - 1].id > items[i].id) { + throw new Error("unstable sort at " + i); + } + } + sum += value; + hash = (hash * 31 + value) % 2147483647; + if (kind === "object") hash = (hash * 31 + items[i].id) % 2147483647; + } + console.log(JSON.stringify({distribution, kind, n, round, sortMs, length: items.length, sum, hash, items})); +} diff --git a/benchmarks/array-sort/issue-289.ts b/benchmarks/array-sort/issue-289.ts new file mode 100644 index 0000000000..fa473faa5f --- /dev/null +++ b/benchmarks/array-sort/issue-289.ts @@ -0,0 +1,16 @@ +const n = Number(process.argv[2] || "100000"); +const res: number[] = []; +for (let i = 0; i < n; i++) { + res.push(i * -2); +} +const start = performance.now(); +res.sort((a, b) => a - b); +const sortMs = performance.now() - start; +let checksum = 0; +for (let i = 0; i < n; i++) { + if (res[i] !== (n - 1 - i) * -2) { + throw new Error("Incorrect sort at index " + i); + } + checksum += res[i]; +} +console.log(JSON.stringify({n, sortMs, length: res.length, first: res[0], last: res[n-1], checksum})); diff --git a/benchmarks/array-sort/measured-m1-max.json b/benchmarks/array-sort/measured-m1-max.json new file mode 100644 index 0000000000..3c165214cc --- /dev/null +++ b/benchmarks/array-sort/measured-m1-max.json @@ -0,0 +1,1843 @@ +{ + "platform": "macOS-26.5-arm64-arm-64bit-Mach-O", + "size": 100000, + "method": "2 warmups + 9 randomized interleaved fresh-process samples per engine. performance.now measures sort only. Matrix: identical pre-generated JSON inputs; every output element, type and stable object order checked against Node. Issue 289: each engine constructs the original negative-number input; the program verifies every output element. Matching compiler/runtime pairs and identical build flags; source differences are the intended optimization patch.", + "node": "v26.5.1", + "source_sha256": { + "bench.ts": "43d0eabb68def87970b1cf79ccb2243b02c9ab4812b0af49e4ffd6dbdd4768f5", + "issue-289.ts": "d2efbc44c796fdbbb5c69f008af8766200df246af9aa9ccd196a6910d043562a" + }, + "harness_sha256": "5d705b48908e0a176c12f92f5561b8dd51883344b190d8b2c86f139e3f2eb0ae", + "builds": { + "baseline": { + "directory": "baseline", + "version": "perry 0.5.1531", + "sha256": { + "perry": "9f4f35b52bedde9afb9a4335cc04f407f72c262e21beee53816987cac69e0d5e", + "libperry_runtime.a": "e125a2d0e2f9c74cf3bfdae02dbb08a13f66a19119b99b465c7f8c8ec8635156", + "libperry_stdlib.a": "112c80d136a75a281aab878dbb0c3a9ac1d6409d3a63074c66ce02582018cf6c" + } + }, + "candidate": { + "directory": "candidate", + "version": "perry 0.5.1531", + "sha256": { + "perry": "be028fd3ab58ed64b3baa1b4c08eb733572f3271050bc1f2f6519f890e1abfbb", + "libperry_runtime.a": "9b4201e98ced81f51a04c329f26a59090a3dc285a4c905efba5a4e532cb0ebdf", + "libperry_stdlib.a": "98c8dfde86adcb6e9d8a76a38ec073146806fa6d7dbd196eb40f2eb193f4945c" + } + } + }, + "rows": [ + { + "distribution": "random", + "kind": "number", + "output_sha256": "3de71a429e36bf3bae382c62281a421c59008046dd77f41c2bd2b5710891eba8", + "timings_ms": { + "node": { + "median": 17.959207999999997, + "min": 17.7055, + "max": 18.653042, + "samples": [ + 18.653042, + 18.073040999999996, + 18.004166000000005, + 17.959207999999997, + 17.826708000000004, + 17.827917, + 17.7055, + 17.973750000000003, + 17.796625000000006 + ] + }, + "baseline": { + "median": 75.01575, + "min": 74.443666, + "max": 79.474583, + "samples": [ + 77.056042, + 75.230208, + 75.01575, + 79.474583, + 74.443666, + 75.729, + 74.692334, + 74.77545900000001, + 74.679 + ] + }, + "candidate": { + "median": 15.855875, + "min": 15.305959, + "max": 16.190707999999997, + "samples": [ + 16.190707999999997, + 16.043625, + 16.141625, + 15.855875, + 15.489832999999999, + 15.653250000000002, + 15.305959, + 15.454333000000002, + 15.93875 + ] + } + } + }, + { + "distribution": "random", + "kind": "object", + "output_sha256": "19e1c5c7307a7f281298299de048087b6a9988c4883933767df506e9e14e2c87", + "timings_ms": { + "node": { + "median": 21.619957999999997, + "min": 20.880917000000004, + "max": 24.10837500000001, + "samples": [ + 21.965999999999994, + 21.418125000000003, + 21.720707999999995, + 21.511332999999993, + 24.10837500000001, + 21.604791000000006, + 21.632542, + 21.619957999999997, + 20.880917000000004 + ] + }, + "baseline": { + "median": 104.566667, + "min": 101.860875, + "max": 109.730333, + "samples": [ + 103.66270800000001, + 101.860875, + 104.89120899999999, + 105.398084, + 106.706042, + 109.730333, + 103.250375, + 104.566667, + 104.464959 + ] + }, + "candidate": { + "median": 24.026582999999995, + "min": 23.449208, + "max": 25.086916999999993, + "samples": [ + 23.770666999999996, + 24.183125, + 23.652834, + 25.086916999999993, + 24.908958000000002, + 24.026582999999995, + 23.793957999999996, + 23.449208, + 24.117958 + ] + } + } + }, + { + "distribution": "random", + "kind": "string", + "output_sha256": "169e7080ad2cfe46eeac9fcfdd9309e2e4550d18acbae64a78b18a9c9a9e4365", + "timings_ms": { + "node": { + "median": 37.682667, + "min": 36.092707999999995, + "max": 41.326417, + "samples": [ + 37.7085, + 37.430749999999996, + 37.682667, + 38.95912499999999, + 41.326417, + 37.085584, + 37.15825, + 36.092707999999995, + 38.597208 + ] + }, + "baseline": { + "median": 241.74866600000001, + "min": 238.705584, + "max": 249.14829200000003, + "samples": [ + 249.14829200000003, + 241.74866600000001, + 242.32166600000002, + 241.455958, + 243.401792, + 238.705584, + 239.53300000000004, + 247.090166, + 241.14875 + ] + }, + "candidate": { + "median": 28.546958000000004, + "min": 26.406207999999985, + "max": 29.836124999999996, + "samples": [ + 27.680041000000003, + 27.377625000000002, + 28.656917, + 29.836124999999996, + 28.546958000000004, + 28.708082999999995, + 26.81566699999999, + 28.659250000000007, + 26.406207999999985 + ] + } + } + }, + { + "distribution": "sorted", + "kind": "number", + "output_sha256": "7a4219f064840822c65de59d629d10477072a7fdf2e3439ed3c8f370ac96b0b4", + "timings_ms": { + "node": { + "median": 1.2370000000000019, + "min": 1.2015409999999989, + "max": 1.264958, + "samples": [ + 1.257125000000002, + 1.2333330000000018, + 1.2370000000000019, + 1.264958, + 1.250707999999996, + 1.2150000000000034, + 1.2015409999999989, + 1.2245419999999996, + 1.2417499999999961 + ] + }, + "baseline": { + "median": 30.275, + "min": 29.193917000000003, + "max": 32.504666, + "samples": [ + 29.193917000000003, + 32.504666, + 30.518, + 30.275, + 29.745000000000005, + 29.560125, + 29.508999999999993, + 30.291584, + 31.250458000000002 + ] + }, + "candidate": { + "median": 0.6184170000000009, + "min": 0.5672499999999996, + "max": 0.6807920000000021, + "samples": [ + 0.5672499999999996, + 0.6807920000000021, + 0.6606249999999996, + 0.6184170000000009, + 0.6230830000000012, + 0.5910419999999998, + 0.5799169999999982, + 0.5736670000000004, + 0.6294580000000014 + ] + } + } + }, + { + "distribution": "sorted", + "kind": "object", + "output_sha256": "472dbccceebee8d99a49c955e6dc4529e3b74bc3151378c43fb78b6758342a36", + "timings_ms": { + "node": { + "median": 1.3113339999999951, + "min": 1.246583000000001, + "max": 1.4299579999999992, + "samples": [ + 1.2588329999999956, + 1.3113339999999951, + 1.3120830000000012, + 1.28275, + 1.246583000000001, + 1.2582499999999968, + 1.3256250000000023, + 1.4299579999999992, + 1.3303749999999965 + ] + }, + "baseline": { + "median": 38.07687500000001, + "min": 37.428875000000005, + "max": 38.284, + "samples": [ + 38.07791700000001, + 37.428875000000005, + 37.811791, + 38.07687500000001, + 37.666792, + 37.836791999999996, + 38.243333, + 38.243165999999995, + 38.284 + ] + }, + "candidate": { + "median": 0.8774169999999994, + "min": 0.8250419999999998, + "max": 0.9563749999999995, + "samples": [ + 0.9563749999999995, + 0.8897499999999994, + 0.9439170000000008, + 0.9202919999999999, + 0.8250419999999998, + 0.8378340000000009, + 0.8774169999999994, + 0.8332500000000014, + 0.8404579999999999 + ] + } + } + }, + { + "distribution": "sorted", + "kind": "string", + "output_sha256": "4d0bf0b0d17c8184b1b65456519cefbe06cd439148241db526f1782080820664", + "timings_ms": { + "node": { + "median": 2.8562919999999963, + "min": 2.1886670000000095, + "max": 8.847083000000012, + "samples": [ + 8.847083000000012, + 5.633375000000001, + 2.273167000000001, + 2.1886670000000095, + 2.8562919999999963, + 5.070750000000004, + 2.3152919999999995, + 2.825958, + 3.0360000000000014 + ] + }, + "baseline": { + "median": 91.19000000000003, + "min": 76.718875, + "max": 127.968625, + "samples": [ + 91.19000000000003, + 127.92766600000002, + 76.718875, + 77.83375000000001, + 87.56533300000001, + 127.968625, + 105.87087500000001, + 84.05362500000001, + 95.08337499999999 + ] + }, + "candidate": { + "median": 1.3262090000000057, + "min": 0.9835419999999999, + "max": 4.064208999999998, + "samples": [ + 1.0148750000000035, + 1.3804590000000019, + 1.273707999999992, + 1.9598749999999967, + 2.3316249999999954, + 0.9941250000000039, + 1.3262090000000057, + 4.064208999999998, + 0.9835419999999999 + ] + } + } + }, + { + "distribution": "reverse", + "kind": "number", + "output_sha256": "2513569c5390ef3d275dfa751b01a66078b5b531e2dc6dd3461cc95d683ddba1", + "timings_ms": { + "node": { + "median": 1.329584000000004, + "min": 1.2278750000000045, + "max": 1.902791999999991, + "samples": [ + 1.902791999999991, + 1.255583999999999, + 1.296167000000004, + 1.2278750000000045, + 1.4121250000000032, + 1.4780409999999975, + 1.329584000000004, + 1.2476659999999953, + 1.4009580000000028 + ] + }, + "baseline": { + "median": 87.944584, + "min": 87.16941700000001, + "max": 144.10045799999997, + "samples": [ + 144.10045799999997, + 115.185916, + 88.310333, + 87.211, + 87.944584, + 87.16941700000001, + 87.76175, + 88.057875, + 87.373459 + ] + }, + "candidate": { + "median": 0.6317079999999997, + "min": 0.5688749999999985, + "max": 0.9020410000000005, + "samples": [ + 0.644124999999999, + 0.9020410000000005, + 0.6143330000000002, + 0.6735000000000007, + 0.6182909999999993, + 0.5981659999999991, + 0.7425419999999985, + 0.6317079999999997, + 0.5688749999999985 + ] + } + } + }, + { + "distribution": "reverse", + "kind": "object", + "output_sha256": "d3d98b8b45f5b75cc6b5b61b649d88836efeee6b1124240d9be106f602b38fb9", + "timings_ms": { + "node": { + "median": 1.3842079999999939, + "min": 1.27975, + "max": 1.467291000000003, + "samples": [ + 1.404875000000004, + 1.3842079999999939, + 1.3423330000000036, + 1.3964580000000026, + 1.467291000000003, + 1.27975, + 1.3445000000000036, + 1.4228330000000042, + 1.3631669999999971 + ] + }, + "baseline": { + "median": 121.68612499999999, + "min": 120.393792, + "max": 123.213958, + "samples": [ + 121.3955, + 123.213958, + 121.69854099999999, + 122.75662500000001, + 122.55387499999999, + 121.68612499999999, + 120.393792, + 121.042542, + 120.663625 + ] + }, + "candidate": { + "median": 0.9042500000000011, + "min": 0.8807079999999985, + "max": 1.0102910000000005, + "samples": [ + 1.0102910000000005, + 0.8888749999999987, + 0.8807079999999985, + 0.9042500000000011, + 0.9924169999999997, + 0.9074999999999989, + 0.980417000000001, + 0.8919170000000012, + 0.8864999999999998 + ] + } + } + }, + { + "distribution": "reverse", + "kind": "string", + "output_sha256": "a6c255697f71b169d06f247b85b65557e495fa51f82d81057532ca3d58a9ecc1", + "timings_ms": { + "node": { + "median": 1.797417000000003, + "min": 1.7577500000000015, + "max": 1.8575000000000017, + "samples": [ + 1.797417000000003, + 1.8049999999999997, + 1.7625829999999993, + 1.8575000000000017, + 1.7925419999999974, + 1.7740409999999969, + 1.812749999999994, + 1.833874999999999, + 1.7577500000000015 + ] + }, + "baseline": { + "median": 275.848083, + "min": 274.659041, + "max": 334.986667, + "samples": [ + 275.1485, + 274.659041, + 334.986667, + 275.192416, + 275.664917, + 276.70670800000005, + 275.848083, + 277.224125, + 276.82495800000004 + ] + }, + "candidate": { + "median": 1.3143340000000023, + "min": 1.2512080000000054, + "max": 1.5538329999999974, + "samples": [ + 1.2512080000000054, + 1.3229169999999968, + 1.3143340000000023, + 1.3714999999999975, + 1.2898330000000016, + 1.3140829999999966, + 1.5538329999999974, + 1.279833999999994, + 1.3575409999999977 + ] + } + } + }, + { + "distribution": "equal", + "kind": "number", + "output_sha256": "750fcfbcbb093c69e4586a7ccd4e321bfeafd92ab11d1c720b60c710a2d13077", + "timings_ms": { + "node": { + "median": 1.1898330000000001, + "min": 1.149208999999999, + "max": 1.247250000000001, + "samples": [ + 1.193249999999999, + 1.149208999999999, + 1.2082499999999996, + 1.165582999999998, + 1.247250000000001, + 1.1898330000000001, + 1.1781250000000014, + 1.182292000000004, + 1.229582999999998 + ] + }, + "baseline": { + "median": 29.5995, + "min": 29.147541, + "max": 52.52595900000001, + "samples": [ + 29.177500000000002, + 29.672958, + 29.490292000000007, + 29.841292000000003, + 29.272041, + 29.5995, + 52.52595900000001, + 29.870082999999994, + 29.147541 + ] + }, + "candidate": { + "median": 0.5791670000000009, + "min": 0.5499999999999998, + "max": 0.9141660000000016, + "samples": [ + 0.565792000000001, + 0.6212079999999993, + 0.597999999999999, + 0.5897079999999999, + 0.5610420000000005, + 0.5499999999999998, + 0.9141660000000016, + 0.5791670000000009, + 0.5527909999999983 + ] + } + } + }, + { + "distribution": "equal", + "kind": "object", + "output_sha256": "aaedace4dae83b82b0722e037b29adb5d37b7ddd7d9b7837ba0407e54dbdc9b4", + "timings_ms": { + "node": { + "median": 1.2686250000000001, + "min": 1.2291669999999968, + "max": 1.4248329999999996, + "samples": [ + 1.2398330000000044, + 1.2291669999999968, + 1.338208999999999, + 1.2835830000000001, + 1.310333, + 1.4248329999999996, + 1.2325000000000017, + 1.2495410000000007, + 1.2686250000000001 + ] + }, + "baseline": { + "median": 37.708583, + "min": 37.530333000000006, + "max": 38.104792, + "samples": [ + 37.682625, + 37.614833, + 37.71362499999999, + 37.708583, + 37.6165, + 38.104792, + 38.063292000000004, + 37.815374999999996, + 37.530333000000006 + ] + }, + "candidate": { + "median": 0.8454999999999995, + "min": 0.7877920000000014, + "max": 0.8983340000000002, + "samples": [ + 0.8781250000000007, + 0.7877920000000014, + 0.8686659999999993, + 0.8707919999999998, + 0.8454999999999995, + 0.8289159999999995, + 0.8269169999999999, + 0.8983340000000002, + 0.792917000000001 + ] + } + } + }, + { + "distribution": "equal", + "kind": "string", + "output_sha256": "43d7c62f3ac9063c893c58d3c8314d54ff09f40c898f310d7826b69b8a727d8e", + "timings_ms": { + "node": { + "median": 1.9862919999999988, + "min": 1.8859999999999957, + "max": 2.0587499999999963, + "samples": [ + 2.0012919999999994, + 1.8859999999999957, + 1.9698339999999988, + 1.9408750000000055, + 1.9997910000000019, + 1.9961660000000023, + 1.976666999999999, + 2.0587499999999963, + 1.9862919999999988 + ] + }, + "baseline": { + "median": 90.216458, + "min": 89.42945900000001, + "max": 92.919041, + "samples": [ + 90.11212499999999, + 90.216458, + 89.92275000000001, + 90.341667, + 89.42945900000001, + 90.683416, + 89.904917, + 91.89579099999999, + 92.919041 + ] + }, + "candidate": { + "median": 1.8743329999999965, + "min": 1.8152500000000025, + "max": 2.1175420000000003, + "samples": [ + 1.8152500000000025, + 1.8743329999999965, + 2.0627080000000007, + 1.8333750000000002, + 2.1175420000000003, + 1.8377500000000033, + 1.956790999999999, + 1.872917000000001, + 1.9732500000000002 + ] + } + } + }, + { + "distribution": "duplicates", + "kind": "number", + "output_sha256": "ecbce18b77f81f2281c6a60484556fe2bbc42ccd807e9cadcd9597374f1aa9df", + "timings_ms": { + "node": { + "median": 9.589042, + "min": 9.249917000000003, + "max": 9.891292, + "samples": [ + 9.254416999999997, + 9.599166000000004, + 9.589042, + 9.891292, + 9.476915999999996, + 9.851917, + 9.249917000000003, + 9.681000000000004, + 9.364083999999998 + ] + }, + "baseline": { + "median": 60.552249999999994, + "min": 59.31479199999999, + "max": 61.701707999999996, + "samples": [ + 60.583458, + 61.701707999999996, + 60.26404199999999, + 61.53224999999999, + 60.98475000000001, + 59.51620799999999, + 59.31479199999999, + 59.643834, + 60.552249999999994 + ] + }, + "candidate": { + "median": 6.493958000000001, + "min": 6.232416999999998, + "max": 6.787125, + "samples": [ + 6.621958000000001, + 6.787125, + 6.461458, + 6.266042000000002, + 6.319667000000001, + 6.6903749999999995, + 6.493958000000001, + 6.542375, + 6.232416999999998 + ] + } + } + }, + { + "distribution": "duplicates", + "kind": "object", + "output_sha256": "66e69bfb1c4a00abb5bf3dd856dc07e1403ac14292300099caf2e5f985d00196", + "timings_ms": { + "node": { + "median": 10.764249999999997, + "min": 10.587291999999998, + "max": 11.273209000000001, + "samples": [ + 10.764249999999997, + 10.807958999999997, + 10.813375, + 10.587291999999998, + 11.174624999999999, + 11.273209000000001, + 10.72025, + 10.588124999999998, + 10.722832999999994 + ] + }, + "baseline": { + "median": 83.01470899999998, + "min": 82.106208, + "max": 85.61375, + "samples": [ + 85.61375, + 83.487625, + 82.908583, + 82.696833, + 82.106208, + 85.200666, + 84.714417, + 83.01470899999998, + 82.583 + ] + }, + "candidate": { + "median": 9.556834, + "min": 9.183583, + "max": 9.714958000000001, + "samples": [ + 9.714958000000001, + 9.399334, + 9.183583, + 9.291625, + 9.217125000000001, + 9.686667000000002, + 9.580083000000002, + 9.556834, + 9.65 + ] + } + } + }, + { + "distribution": "duplicates", + "kind": "string", + "output_sha256": "865a902dbc89f1aedf6a1ff2976abe6c9e347d21abcd70a673dfbe54ebd7371f", + "timings_ms": { + "node": { + "median": 14.628416000000001, + "min": 14.033290999999991, + "max": 15.058666000000002, + "samples": [ + 14.3765, + 14.266832999999991, + 14.840083, + 14.720082999999995, + 14.781583999999995, + 15.058666000000002, + 14.033290999999991, + 14.276791000000003, + 14.628416000000001 + ] + }, + "baseline": { + "median": 226.05658400000002, + "min": 220.84608400000002, + "max": 231.154625, + "samples": [ + 226.72733300000002, + 231.154625, + 226.586458, + 225.8345, + 227.706292, + 220.84608400000002, + 223.830375, + 224.249042, + 226.05658400000002 + ] + }, + "candidate": { + "median": 12.454749999999997, + "min": 12.314083999999998, + "max": 12.796583000000002, + "samples": [ + 12.454749999999997, + 12.439083000000004, + 12.513542000000001, + 12.368041999999996, + 12.314083999999998, + 12.646332999999995, + 12.547124999999998, + 12.796583000000002, + 12.384166999999998 + ] + } + } + }, + { + "distribution": "runs", + "kind": "number", + "output_sha256": "cf5bfc7ea0bdc374b5014c1d4f42ed6d0f66c791595cc9c950a9ebbfd10f44d9", + "timings_ms": { + "node": { + "median": 2.5273340000000033, + "min": 2.4066250000000053, + "max": 2.678457999999999, + "samples": [ + 2.6398340000000005, + 2.5273340000000033, + 2.5497920000000036, + 2.5542919999999967, + 2.5137920000000022, + 2.678457999999999, + 2.4599580000000003, + 2.5210420000000013, + 2.4066250000000053 + ] + }, + "baseline": { + "median": 34.478167, + "min": 34.100207999999995, + "max": 34.940332999999995, + "samples": [ + 34.940332999999995, + 34.6175, + 34.501707999999994, + 34.358208000000005, + 34.467666, + 34.478167, + 34.25125, + 34.100207999999995, + 34.593292 + ] + }, + "candidate": { + "median": 0.860125, + "min": 0.7894170000000003, + "max": 0.9024170000000016, + "samples": [ + 0.8611250000000013, + 0.7894170000000003, + 0.8174580000000002, + 0.8200420000000008, + 0.8673750000000009, + 0.8877500000000005, + 0.8072909999999993, + 0.9024170000000016, + 0.860125 + ] + } + } + }, + { + "distribution": "runs", + "kind": "object", + "output_sha256": "1438ae13edc0a545bf00cd3af85e9e59a9425ed0363683a85a1c9c3a38d643ec", + "timings_ms": { + "node": { + "median": 2.6004580000000033, + "min": 2.5871250000000003, + "max": 2.779541000000002, + "samples": [ + 2.5871250000000003, + 2.5969580000000008, + 2.594208000000002, + 2.721499999999999, + 2.5901250000000005, + 2.6004580000000033, + 2.637750000000004, + 2.779541000000002, + 2.6345830000000063 + ] + }, + "baseline": { + "median": 45.640457999999995, + "min": 45.167666999999994, + "max": 46.374165999999995, + "samples": [ + 45.2295, + 45.167666999999994, + 46.374165999999995, + 45.561458, + 45.942541, + 45.640457999999995, + 45.22395900000001, + 45.963292, + 45.916791999999994 + ] + }, + "candidate": { + "median": 1.2272500000000015, + "min": 1.1142079999999996, + "max": 1.3735420000000005, + "samples": [ + 1.2345000000000006, + 1.3351250000000014, + 1.1708749999999988, + 1.2272500000000015, + 1.1575420000000012, + 1.3735420000000005, + 1.1200840000000003, + 1.1142079999999996, + 1.2865420000000007 + ] + } + } + }, + { + "distribution": "runs", + "kind": "string", + "output_sha256": "8dcd47e44217379e5a5d98fee86f690d8d6d8bd9ec7f9f931d2f9e311a3a2a12", + "timings_ms": { + "node": { + "median": 3.670333999999997, + "min": 3.498375000000003, + "max": 3.9398749999999936, + "samples": [ + 3.8131250000000065, + 3.619125000000004, + 3.565415999999999, + 3.9398749999999936, + 3.670333999999997, + 3.764125, + 3.770832999999996, + 3.498375000000003, + 3.627333 + ] + }, + "baseline": { + "median": 100.67029199999999, + "min": 99.182208, + "max": 102.80770899999999, + "samples": [ + 101.53045900000001, + 102.80770899999999, + 101.21112499999998, + 99.73245800000002, + 99.182208, + 100.67029199999999, + 101.71225000000003, + 99.80975000000001, + 99.803125 + ] + }, + "candidate": { + "median": 1.3569999999999993, + "min": 1.273666999999996, + "max": 1.4532919999999976, + "samples": [ + 1.273666999999996, + 1.368666999999995, + 1.4183749999999975, + 1.336500000000008, + 1.3558749999999975, + 1.4532919999999976, + 1.3569999999999993, + 1.4317920000000015, + 1.2802080000000018 + ] + } + } + }, + { + "distribution": "nearly_sorted", + "kind": "number", + "output_sha256": "e5e4c9fd6fc932012c1d7440cc83b296e1cb9766928ece7b46c47c371578c512", + "timings_ms": { + "node": { + "median": 2.6562079999999995, + "min": 2.538374999999995, + "max": 2.827541999999994, + "samples": [ + 2.722999999999999, + 2.827541999999994, + 2.789500000000004, + 2.538374999999995, + 2.746417000000001, + 2.648875000000004, + 2.586999999999996, + 2.6562079999999995, + 2.623874999999998 + ] + }, + "baseline": { + "median": 32.143333000000005, + "min": 31.8305, + "max": 32.640209000000006, + "samples": [ + 32.640209000000006, + 32.515292, + 31.877625000000002, + 32.21495900000001, + 32.039041, + 32.143333000000005, + 31.8305, + 31.943708, + 32.36125 + ] + }, + "candidate": { + "median": 1.066791000000002, + "min": 1.0119160000000011, + "max": 1.1099999999999994, + "samples": [ + 1.10825, + 1.091666, + 1.0187500000000007, + 1.066791000000002, + 1.0969169999999995, + 1.1099999999999994, + 1.039041000000001, + 1.0184999999999995, + 1.0119160000000011 + ] + } + } + }, + { + "distribution": "nearly_sorted", + "kind": "object", + "output_sha256": "d0f77fc2c117e75e8710a9f12758032a881b848c6bdc4f77265ae336c6855102", + "timings_ms": { + "node": { + "median": 2.8937919999999977, + "min": 2.774708000000004, + "max": 2.988958999999994, + "samples": [ + 2.9293750000000003, + 2.8937919999999977, + 2.970000000000006, + 2.774708000000004, + 2.939458000000002, + 2.865333999999997, + 2.988958999999994, + 2.857666000000002, + 2.8184169999999966 + ] + }, + "baseline": { + "median": 43.932, + "min": 43.548167, + "max": 44.684583, + "samples": [ + 44.250916000000004, + 44.20875, + 43.548167, + 44.684583, + 43.60475, + 43.721917000000005, + 43.932, + 43.696834, + 44.360417000000005 + ] + }, + "candidate": { + "median": 1.5780000000000012, + "min": 1.5294160000000012, + "max": 1.7094170000000002, + "samples": [ + 1.7094170000000002, + 1.6874579999999995, + 1.5780000000000012, + 1.6367499999999993, + 1.551957999999999, + 1.6647920000000003, + 1.555083999999999, + 1.550167, + 1.5294160000000012 + ] + } + } + }, + { + "distribution": "nearly_sorted", + "kind": "string", + "output_sha256": "53bf698581c4c1f7a44da0e4c03924ed74c6959ddef010f7610bbefb8567f69f", + "timings_ms": { + "node": { + "median": 4.2158330000000035, + "min": 4.0021249999999995, + "max": 5.832041000000004, + "samples": [ + 4.0021249999999995, + 4.0199169999999995, + 4.328958, + 4.191375000000001, + 4.2158330000000035, + 5.832041000000004, + 4.018041000000004, + 4.252416000000004, + 4.391332999999996 + ] + }, + "baseline": { + "median": 99.557667, + "min": 98.94225, + "max": 109.94158300000001, + "samples": [ + 99.557667, + 99.20637499999998, + 99.32354200000002, + 99.293, + 109.94158300000001, + 103.43520800000002, + 99.757834, + 100.26904199999998, + 98.94225 + ] + }, + "candidate": { + "median": 1.8823750000000032, + "min": 1.7070000000000007, + "max": 2.001624999999997, + "samples": [ + 1.7070000000000007, + 2.001624999999997, + 1.7462500000000034, + 1.7447080000000028, + 1.9002089999999967, + 1.815166000000005, + 1.8823750000000032, + 1.9838330000000042, + 1.8848329999999933 + ] + } + } + }, + { + "distribution": "organ_pipe", + "kind": "number", + "output_sha256": "3249b1f21803b2fcfd47184dbd02b1e60f7e8ad26b5f1418bcd9a00e3c806e99", + "timings_ms": { + "node": { + "median": 2.094000000000001, + "min": 1.9891669999999948, + "max": 2.1480829999999997, + "samples": [ + 1.9891669999999948, + 2.1235420000000005, + 2.1480829999999997, + 2.0487910000000014, + 2.084333000000001, + 2.086584000000002, + 2.1060000000000016, + 2.094000000000001, + 2.0949580000000054 + ] + }, + "baseline": { + "median": 58.709500000000006, + "min": 58.104583999999996, + "max": 59.896542, + "samples": [ + 58.507625000000004, + 58.72595800000001, + 58.749541, + 59.896542, + 59.167666999999994, + 58.613333, + 58.104583999999996, + 58.709500000000006, + 58.600083 + ] + }, + "candidate": { + "median": 0.8895410000000012, + "min": 0.8564579999999999, + "max": 0.977583000000001, + "samples": [ + 0.977583000000001, + 0.8564579999999999, + 0.8895410000000012, + 0.9070409999999995, + 0.8641670000000001, + 0.9733750000000008, + 0.9225830000000013, + 0.8774999999999995, + 0.8814580000000003 + ] + } + } + }, + { + "distribution": "organ_pipe", + "kind": "object", + "output_sha256": "cd16fbe1cbd46c55067b780148c3cc0599ee19bba7ad2e762bb1adf2d9fa0bfd", + "timings_ms": { + "node": { + "median": 2.147750000000002, + "min": 2.0644589999999994, + "max": 2.2127499999999998, + "samples": [ + 2.1823750000000004, + 2.1428749999999965, + 2.147750000000002, + 2.1823339999999973, + 2.1550829999999976, + 2.0644589999999994, + 2.2127499999999998, + 2.1117910000000037, + 2.0735409999999987 + ] + }, + "baseline": { + "median": 79.58104200000001, + "min": 79.28562499999998, + "max": 80.828291, + "samples": [ + 79.39862500000001, + 79.34625, + 80.828291, + 79.58104200000001, + 80.616458, + 79.361541, + 79.76625000000001, + 79.28562499999998, + 79.773583 + ] + }, + "candidate": { + "median": 1.446457999999998, + "min": 1.3729999999999993, + "max": 1.5750420000000016, + "samples": [ + 1.446457999999998, + 1.3729999999999993, + 1.414625000000001, + 1.5750420000000016, + 1.5357499999999984, + 1.388458, + 1.5084169999999997, + 1.4759159999999998, + 1.390709000000001 + ] + } + } + }, + { + "distribution": "organ_pipe", + "kind": "string", + "output_sha256": "891257f2456ea590bb12cf6da913e2dafec8c5580c9b62e5ded2e77bea2f8da7", + "timings_ms": { + "node": { + "median": 3.1923340000000024, + "min": 3.1167919999999967, + "max": 3.342374999999997, + "samples": [ + 3.320208000000001, + 3.169542, + 3.151416999999995, + 3.1923340000000024, + 3.2789590000000004, + 3.2688750000000013, + 3.152417, + 3.342374999999997, + 3.1167919999999967 + ] + }, + "baseline": { + "median": 172.566375, + "min": 172.03025, + "max": 174.31375, + "samples": [ + 172.43379099999999, + 172.82887499999998, + 172.438916, + 172.566375, + 172.152417, + 174.14800000000002, + 172.03025, + 172.90933299999998, + 174.31375 + ] + }, + "candidate": { + "median": 1.958834000000003, + "min": 1.882124999999995, + "max": 2.4329589999999968, + "samples": [ + 1.9625419999999991, + 1.9423750000000055, + 2.4329589999999968, + 2.0310419999999922, + 1.9421669999999978, + 1.958834000000003, + 1.9950410000000005, + 1.9099999999999966, + 1.882124999999995 + ] + } + } + }, + { + "distribution": "issue_289", + "kind": "number", + "output_sha256": "e7d44b16dfffbe10c791ffa900faca0a975e322f1299d867d48167091a2fc714", + "timings_ms": { + "node": { + "median": 1.6795000000000044, + "min": 1.593916, + "max": 1.7247500000000002, + "samples": [ + 1.638582999999997, + 1.6837909999999994, + 1.593916, + 1.7147920000000028, + 1.7247500000000002, + 1.6801250000000039, + 1.6468330000000009, + 1.6795000000000044, + 1.619125000000004 + ] + }, + "baseline": { + "median": 90.97079099999999, + "min": 90.343584, + "max": 92.79337500000001, + "samples": [ + 90.97079099999999, + 90.81845899999999, + 90.343584, + 90.736542, + 92.79337500000001, + 91.749291, + 90.92949999999999, + 91.32595799999999, + 92.15754199999999 + ] + }, + "candidate": { + "median": 0.6118330000000001, + "min": 0.5691670000000001, + "max": 0.646416, + "samples": [ + 0.6211660000000001, + 0.5934579999999999, + 0.5691670000000001, + 0.5855000000000001, + 0.6118330000000001, + 0.589541, + 0.625917, + 0.6250409999999998, + 0.646416 + ] + } + } + } + ], + "baseline_commit": "1a9c0de6cb790d2467b0ca22a660870025179b37", + "candidate_source_sha256": { + "crates/perry-codegen/src/expr/compare.rs": "223be5f20d77ce58fae5f21053735ecf13234897579da2fbd36d79ff9fc4f2cb", + "crates/perry-codegen/src/expr/compare_short_string.rs": "dfd8f4e5715377c6d742b380bce4ffc2a3ff6de64a782a63457bd1596e83b87b", + "crates/perry-codegen/src/expr/compare_tests.rs": "72d446be5e2c86897365b09974b67bda8aa54b0bfcc1c9ce558e5eda11543b6e", + "crates/perry-codegen/src/expr/property_get/generic_dispatch.rs": "6c860e695601c9c855c06280d30d49f825bc4b1c784a12ad92c4d9082e5a1546", + "crates/perry-codegen/src/expr/property_get/tests.rs": "d76628e764ddec7b464a06267ed32c183538b4fe9b2f150c70b37b411c92a132", + "crates/perry-codegen/src/gc_call_effects.rs": "7efdf1a389f99418994fe2f69dc1221bb8950048d244918f31252e74f6c0a81a", + "crates/perry-codegen/src/module.rs": "398452da31de9b938bb99ead0fab3e373ccb1c39e311579de6f9cc7634826f0c", + "crates/perry-codegen/src/module/linkage.rs": "1d52c0e99c98f925b668d9e4d1a3b89f4c8cea9d2d0749b39473c6872737b83e", + "crates/perry-codegen/src/root_reload.rs": "343e7efb2063b722eba7bd1e602e75487f8bed1b5ce46578e4328df0571b3432", + "crates/perry-codegen/src/root_reload_tests.rs": "2b3cfc61b61a7e452ecc3f0c560019717f78b74e065a7774ec4048c92c94a63f", + "crates/perry-codegen/src/runtime_decls/objects.rs": "2dced6e73184dfdefd98cd88ce6c12d3cacb079357ecac5d2046cbc3c0fae67d", + "crates/perry-codegen/src/stmt/loops.rs": "a7cec2a289635abf33f5670bb2e5503e9db0db33a8390e0a77cfd5524c32e7f0", + "crates/perry-runtime/src/array/generic_object.rs": "3b81768801c50657ce54972c1e86045dc1c567fdd4efba4bf2e4023fb29f5ee8", + "crates/perry-runtime/src/array/header.rs": "d3689441308b576b587eba54e581be39817135fee20934e0e263a8581b02bfec", + "crates/perry-runtime/src/array/header_gc_slots.rs": "c427bd04f2a40334f1f2d91450f658274373e15c78642514e9f359aec7d668de", + "crates/perry-runtime/src/array/push_pop.rs": "f7459444227cccbd788b937e2d4331b2a6412446217f5cac0b3819c678f1f175", + "crates/perry-runtime/src/array/sort.rs": "c1f286ec276c2ad0480eb88db706f73d904d0bd533d810fe03d1d7de993bfb24", + "crates/perry-runtime/src/array/sort_indices.rs": "6b7fb6896a9397d2b7304f855aba722ebd401daf92e06b9ace8f62e65dafab9b", + "crates/perry-runtime/src/builtins/arithmetic.rs": "f6bc1718017d6bdc0c9461cdcd91de49029c2085dd7331d2a38069d689444d8c", + "crates/perry-runtime/src/closure/dispatch/direct.rs": "099046c3707bfa8a2128063e50e923df62826dadaceec161a8e5aaed52549946", + "crates/perry-runtime/src/gc/roots.rs": "010b8665608f717123e7fd8ebd30497cb9a6c03beab5fa66a016c8cf349ff0a0", + "crates/perry-runtime/src/gc/roots/stack_roots.rs": "045d105efe7227df54dcc018a0fb8c5879e81ed1e740d6ef951ee3537b1f3310", + "crates/perry-runtime/src/gc/tests/runtime_roots.rs": "e0f99b22467542238ace8b1bc3fdb7f753edfb8e6fc3ef727216a9a335d9f65d", + "crates/perry-runtime/src/gc/tests/runtime_roots/callback_scanners.rs": "df2d6adabeae3a5138c34475d1661dfc8ce0e61be7f199cdad3cf40f28284f99", + "crates/perry-runtime/src/gc/tests/runtime_roots/sort_collection.rs": "f59886eac2438719fc10eb0774446d6d7307f4cbf23f008d24e4da11362e32aa", + "crates/perry-runtime/src/gc/tests/shadow_stack_ops.rs": "1799d46f5bb25f0dfcaca71f61aeb49d1627cc423b827bf9d8f77ab40e205cb7", + "crates/perry-runtime/src/object/field_get_set.rs": "f1543a32ceed3102f16bbd7a4ee80b7b4bf62a50636a97a0ec3ef1c0c515b5de", + "crates/perry-runtime/src/object/field_get_set/ic_miss.rs": "c294e5f5785ddc712b32cff72402d1d99ac12e064f936e9d03a0c8092edeb866", + "crates/perry-runtime/src/object/field_get_set/ic_miss/c3c_pic_tests.rs": "f5f2a292ea94a6038427743b61bd358b8d8999883a306e6dadfc4ab06963a3aa", + "crates/perry-runtime/src/object/field_get_set/ic_miss/packed_get.rs": "25a1c0758096ac53c4724d5556963b63ee0f7ecb6dae78d5d7adc327923263b2", + "crates/perry-runtime/src/string/compare.rs": "b24477bce48bfdfc3fee9fff130e0bee9a48030350b1e4a57e1fe5a2a0b957a5", + "crates/perry-runtime/src/string/mod.rs": "a56d0bb17c292fdf60f370bced730f593c46f72f29d3a0cc483b24ab763ed569", + "scripts/gc_root_dominance_check.py": "4ed7d2b7f1f5af455fbf7add3caa86f0ebff48e7e4945f431fceda9bb62835cc", + "scripts/raw_handle_debt_baseline.txt": "ff7b2006991d83042658bc009a53f41ce4ba19c95bb1fa1f08fbaf0bb35e5e03", + "scripts/raw_handle_debt_files.txt": "fee0361d3159989eceb8f494f39c491c3570299623157b1bb6f0fcc427c8d377", + "test-files/test_gap_array_adaptive_sort.ts": "0179b83aab691f2bdce54ed5adbd310abf8c70a8f285447c692d453859475560", + "test-files/test_gap_dynamic_property_cache_guards.ts": "bf66e6c3d872d802261fe151264199ccfbaffd5648c68f73aa637706d0a63b0b", + "test-files/test_gap_numeric_tag_guard.ts": "91d20dcd5a8ae276bb81d9f3e1ede382cc97614f6fb59d02473003f2399bcdc5", + "test-files/test_gap_primitive_string_relational.ts": "2b1810ce413e24ad37cbb21265cb3602e33c9ee88f881012ab6cb6b8b9860567" + }, + "build_command": "LLVM_SYS_221_PREFIX=/opt/homebrew/opt/llvm@22 CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 CARGO_PROFILE_RELEASE_LTO=off cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static -j4", + "built_from_commit_plus_recorded_sources": "d4bdb66b3488c897861db2b799f1d502da89c1a7", + "measurement_date": "2026-09-11", + "machine": "Apple M1 Max", + "host_load_note": "Other user workloads were running on this shared host. The harness interleaves engines and retains every sample; ratios are local measurements, not isolated-machine or cross-platform guarantees.", + "candidate_commit": "eeb1a5c3e46e68202e921544572a7ce5ffcfb35b", + "verification_checker_sha256": { + "scripts/shape_descriptor_census.py": "cf77e494f8fd86b9467128d680bfa4b62afc7aac5b34efcff27d103cf4fe25e5" + }, + "targeted_previous_candidate_comparison": { + "previous_candidate_commit": "bad6a860884c4cca872253926de668e414b2123d", + "method": "15 randomized interleaved fresh-process samples after two warmups, with complete canonical JSON output comparison. The previous/current candidate labels correspond to next-16/next-17 frozen matching builds.", + "rows": [ + { + "distribution": "random", + "kind": "object", + "samples": { + "node": [ + 22.236292000000006, + 21.768416999999992, + 23.152209000000006, + 23.390083000000004, + 28.740916, + 21.109165999999995, + 23.144042000000006, + 21.513707999999994, + 22.39533399999999, + 21.586166, + 21.872291999999995, + 22.737249999999996, + 22.823708000000003, + 21.381750000000004, + 21.704958000000005 + ], + "next-16": [ + 24.745333000000002, + 24.230333, + 24.534208, + 23.991917, + 23.71025, + 23.076792000000005, + 25.090333, + 23.876167000000002, + 24.394999999999996, + 24.150334, + 23.405666000000004, + 24.203584000000003, + 25.134666999999993, + 24.12875, + 23.784000000000002 + ], + "next-17": [ + 22.975541999999997, + 24.142707999999995, + 24.673000000000005, + 25.393875, + 23.274125, + 23.589833999999996, + 24.06225, + 23.796541999999995, + 23.7455, + 24.062208, + 24.278042000000003, + 24.314375, + 24.363499999999995, + 24.117084, + 23.151000000000003 + ] + }, + "median_ms": { + "node": 22.236292000000006, + "next-16": 24.150334, + "next-17": 24.06225 + }, + "output_sha256": "74acef4d0e6a1d5a49a7475e35153d7dfb2300fc20011f458864abd3d8fbac51" + }, + { + "distribution": "duplicates", + "kind": "object", + "samples": { + "node": [ + 10.381458000000002, + 10.843083, + 10.46125, + 10.592083000000002, + 10.504208000000006, + 10.207417, + 10.605542, + 10.873000000000005, + 10.089542000000002, + 10.359833000000002, + 10.708583000000004, + 10.679333999999997, + 10.4495, + 10.404499999999999, + 10.720125000000003 + ], + "next-16": [ + 9.604624999999999, + 9.185042000000003, + 9.356834, + 9.252374999999999, + 9.880041999999998, + 9.311208, + 9.420375000000002, + 9.215375, + 9.199708999999997, + 9.859, + 9.175459, + 9.377416000000002, + 9.128208, + 9.490958, + 9.241875000000002 + ], + "next-17": [ + 9.538332999999998, + 9.165625000000002, + 9.102458000000002, + 9.079792000000001, + 8.951875000000001, + 8.982416, + 9.271833999999998, + 8.924333000000003, + 9.088500000000002, + 9.152167000000002, + 9.104334, + 9.253916000000002, + 9.378625, + 8.973749999999999, + 9.14475 + ] + }, + "median_ms": { + "node": 10.504208000000006, + "next-16": 9.311208, + "next-17": 9.104334 + }, + "output_sha256": "5ed36644a029862ef87d3bc73ce7d7c6e1af8e57cc9280f5e2a9d91eeacc276c" + }, + { + "distribution": "sorted", + "kind": "object", + "samples": { + "node": [ + 1.351707999999995, + 1.385040999999994, + 1.355958000000001, + 1.2607499999999945, + 1.2434170000000009, + 1.2493340000000046, + 1.2539170000000013, + 1.3040830000000057, + 1.3400409999999994, + 1.3434590000000028, + 1.4562090000000012, + 1.2779999999999987, + 1.2837079999999972, + 1.308125000000004, + 1.270040999999999 + ], + "next-16": [ + 0.8652090000000001, + 0.9526250000000012, + 0.9152079999999998, + 0.8774170000000012, + 0.9114999999999984, + 0.8979169999999996, + 0.9414999999999996, + 0.947833000000001, + 0.9196250000000017, + 1.0199169999999995, + 0.9403749999999995, + 0.9976669999999999, + 0.9347080000000005, + 0.9542090000000023, + 1.330582999999999 + ], + "next-17": [ + 0.8407909999999994, + 0.8481670000000001, + 0.9370000000000012, + 0.830665999999999, + 0.8485839999999989, + 0.8870000000000005, + 0.8590419999999988, + 0.8777499999999989, + 0.8920000000000012, + 0.8972920000000002, + 0.8884160000000012, + 0.8540420000000015, + 0.8804169999999996, + 0.9069170000000017, + 0.8146660000000008 + ] + }, + "median_ms": { + "node": 1.3040830000000057, + "next-16": 0.9403749999999995, + "next-17": 0.8777499999999989 + }, + "output_sha256": "30c172ebd966078af93c68cc71258227cfe0829243d7bc16153b75507affff8a" + }, + { + "distribution": "organ_pipe", + "kind": "object", + "samples": { + "node": [ + 2.154416999999995, + 2.4158749999999998, + 2.183667, + 2.1788329999999974, + 2.208167000000003, + 2.038333999999999, + 2.050582999999996, + 2.1749579999999966, + 2.0872500000000045, + 2.0302500000000023, + 2.112375, + 2.184375000000003, + 2.158207999999995, + 2.0406249999999986, + 2.2767079999999993 + ], + "next-16": [ + 1.5018329999999995, + 1.4162499999999998, + 1.4485839999999985, + 1.404833, + 1.4678749999999994, + 1.446333000000001, + 1.5937920000000005, + 1.5420420000000004, + 1.6358329999999999, + 1.6357500000000016, + 1.4619160000000004, + 1.5075839999999996, + 1.4629589999999997, + 1.4501249999999999, + 1.4710829999999984 + ], + "next-17": [ + 1.4933340000000008, + 1.437040999999999, + 1.4881250000000001, + 1.4526670000000017, + 1.4361660000000018, + 1.4482090000000003, + 1.3588339999999999, + 1.4327500000000004, + 1.4914159999999992, + 1.6027079999999998, + 1.3920419999999982, + 1.3904999999999994, + 1.4209579999999988, + 1.4961249999999993, + 1.4515829999999994 + ] + }, + "median_ms": { + "node": 2.158207999999995, + "next-16": 1.4678749999999994, + "next-17": 1.4482090000000003 + }, + "output_sha256": "4d62d62cfb54c5902a6f8799f93954e559cbc8d082a4144e2abe82eea22bac09" + }, + { + "distribution": "random", + "kind": "number", + "samples": { + "node": [ + 17.794249999999998, + 17.684041, + 17.739332999999995, + 18.021791999999998, + 17.662459, + 17.709583000000002, + 17.515333, + 17.662750000000003, + 17.792458000000003, + 17.343458, + 17.877333, + 18.05525, + 17.986417000000003, + 17.727708999999997, + 17.747749999999996 + ], + "next-16": [ + 15.272874999999999, + 15.776167000000001, + 15.661250000000003, + 15.691624999999998, + 15.483707999999998, + 15.774083999999997, + 15.633708000000002, + 15.554542, + 15.618041999999999, + 15.727667, + 15.709124999999998, + 15.429500000000003, + 16.638083, + 15.707250000000002, + 15.876042 + ], + "next-17": [ + 15.58, + 15.667917, + 15.487749999999997, + 15.913374999999998, + 15.432415999999998, + 15.791083999999998, + 15.497417, + 15.605291000000001, + 15.495209, + 15.713666000000002, + 15.408624999999997, + 15.753625000000001, + 15.537541999999998, + 16.073416, + 15.519874999999999 + ] + }, + "median_ms": { + "node": 17.739332999999995, + "next-16": 15.691624999999998, + "next-17": 15.58 + }, + "output_sha256": "8fe62d8a137ceab10ec02de9a4542ce7730729804e1641549ce714d7fcbb44c9" + }, + { + "distribution": "random", + "kind": "string", + "samples": { + "node": [ + 34.934041, + 34.816917, + 35.7095, + 37.599416999999995, + 34.681208999999996, + 34.984542, + 35.160540999999995, + 35.759874999999994, + 35.48324999999999, + 35.554, + 34.89441599999999, + 37.243874999999996, + 36.134583000000006, + 38.181667, + 35.804832999999995 + ], + "next-16": [ + 26.435499999999998, + 25.299208, + 26.920584000000005, + 26.771208, + 26.875791999999997, + 27.259000000000007, + 26.275208, + 26.497625000000006, + 24.951417000000006, + 26.552667, + 25.031667, + 25.023290999999993, + 24.63725000000001, + 28.030708000000004, + 26.581125000000007 + ], + "next-17": [ + 26.118874999999996, + 26.774125000000012, + 26.491749999999996, + 25.798625, + 27.62541699999999, + 28.454790999999993, + 25.407542, + 27.284958000000003, + 27.222624999999987, + 25.460583, + 25.582958000000012, + 26.102792, + 26.862125, + 25.430666999999993, + 24.489209000000002 + ] + }, + "median_ms": { + "node": 35.554, + "next-16": 26.497625000000006, + "next-17": 26.118874999999996 + }, + "output_sha256": "1a9521f0a9a0411772b4f6572b438001149332f9e46c85460abfb947a8e67e15" + } + ] + } +} diff --git a/benchmarks/array-sort/run.py b/benchmarks/array-sort/run.py new file mode 100644 index 0000000000..8bd62d2637 --- /dev/null +++ b/benchmarks/array-sort/run.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Generic sort A/B: paired compiler/runtime directories, shared inputs, Node oracle. + +Each directory must contain perry, libperry_runtime.a and libperry_stdlib.a +built together. Example: + python3 benchmarks/array-sort/run.py --baseline /tmp/base --candidate /tmp/new +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import random +import shutil +import statistics +import subprocess +import tempfile + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--baseline", type=Path, required=True) +parser.add_argument("--candidate", type=Path, required=True) +parser.add_argument("--node", default="node") +parser.add_argument("--size", type=int, default=100000) +parser.add_argument("--samples", type=int, default=7) +parser.add_argument("--warmups", type=int, default=2) +parser.add_argument("--output", type=Path) +parser.add_argument("--cases", choices=["all", "matrix", "issue"], default="all") +args = parser.parse_args() +if args.size < 1 or args.samples < 1 or args.warmups < 0: + parser.error("size and samples must be positive; warmups must be nonnegative") +out = (args.output or Path(tempfile.mkdtemp(prefix="perry-array-sort-"))).resolve() +out.mkdir(parents=True, exist_ok=True) +sources = {"matrix": out / "bench.ts", "issue": out / "issue-289.ts"} +if args.cases != "all": + sources = {args.cases: sources[args.cases]} +for source in sources.values(): + shutil.copyfile(Path(__file__).with_name(source.name), source) +env = {k: v for k, v in os.environ.items() if not k.startswith("PERRY_")} +report = { + "platform": platform.platform(), "size": args.size, + "method": f"{args.warmups} warmups + {args.samples} randomized interleaved fresh-process samples per engine. performance.now measures sort only. Matrix: identical pre-generated JSON inputs; every output element, type and stable object order checked against Node. Issue 289: each engine constructs the original negative-number input; the program verifies every output element. Matching compiler/runtime pairs and identical build flags; source differences are the intended optimization patch.", + "node": subprocess.check_output([args.node, "--version"], text=True).strip(), + "source_sha256": {source.name: hashlib.sha256(source.read_bytes()).hexdigest() for source in sources.values()}, + "harness_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + "builds": {}, "rows": [], +} +commands = {case: {"node": [args.node, str(source)]} for case, source in sources.items()} +for name, directory in [("baseline", args.baseline), ("candidate", args.candidate)]: + directory = directory.resolve() + compiler = directory / "perry" + report["builds"][name] = { + "directory": str(directory), + "version": subprocess.check_output([str(compiler), "--version"], text=True).strip(), + "sha256": {file: hashlib.sha256((directory / file).read_bytes()).hexdigest() + for file in ["perry", "libperry_runtime.a", "libperry_stdlib.a"]}, + } + for case, source in sources.items(): + binary = out / f"{name}-{case}" + compile_cmd = [str(compiler), "compile", str(source), "-o", str(binary), + "--no-auto-optimize", "--cache-dir", str(out / "cache")] + with (out / f"compile-{name}-{case}.log").open("w") as log: + subprocess.run(compile_cmd, cwd=out, env={**env, "PERRY_RUNTIME_DIR": str(directory)}, + stdout=log, stderr=subprocess.STDOUT, timeout=180, check=True) + commands[case][name] = [str(binary)] + +n = args.size +rng = random.Random(289) +random_values = [rng.randrange(n) for _ in range(n)] +fixtures = { + "random": random_values, + "sorted": list(range(n)), "reverse": list(reversed(range(n))), + "equal": [3] * n, "duplicates": [x % 8 for x in random_values], + "runs": [(n - 1 - (i // 137) * 137) + i % 137 for i in range(n)], + "nearly_sorted": [random_values[i] if i % 97 == 0 else i for i in range(n)], + "organ_pipe": [min(i, n - 1 - i) for i in range(n)], +} + + +def run(engine, distribution, kind, fixture): + case = "issue" if distribution == "issue_289" else "matrix" + extra = [str(n)] if case == "issue" else [distribution, kind, str(n), "1", str(fixture)] + command = commands[case][engine] + extra + result = subprocess.run(command, cwd=out, env=env, capture_output=True, + text=True, timeout=60, check=True) + if result.stderr: + raise RuntimeError(result.stderr) + record = json.loads(result.stdout) + elapsed = record.pop("sortMs") + assert record["length"] == n + return elapsed, record + + +cases = [] +if "matrix" in commands: + for distribution, values in fixtures.items(): + fixture = out / f"input-{distribution}.json" + fixture.write_text(json.dumps(values, separators=(",", ":"))) + cases.extend(("matrix", distribution, kind, fixture) for kind in ["number", "object", "string"]) +if "issue" in commands: + cases.append(("issue", "issue_289", "number", None)) +for case, distribution, kind, fixture in cases: + _, expected = run("node", distribution, kind, fixture) + # Python considers True == 1. Compare canonical JSON so a wrong JS + # value type cannot pass the oracle check through Python equality. + expected_json = json.dumps(expected, sort_keys=True, separators=(",", ":")) + samples = {name: [] for name in commands[case]} + for iteration in range(args.warmups + args.samples): + order = list(commands[case]) + rng.shuffle(order) + for name in order: + elapsed, record = run(name, distribution, kind, fixture) + assert json.dumps(record, sort_keys=True, separators=(",", ":")) == expected_json, (name, distribution, kind, "output differs from Node") + if iteration >= args.warmups: + samples[name].append(elapsed) + row = {"distribution": distribution, "kind": kind, + "output_sha256": hashlib.sha256(json.dumps(expected, separators=(",", ":")).encode()).hexdigest(), + "timings_ms": {name: {"median": statistics.median(times), "min": min(times), + "max": max(times), "samples": times} + for name, times in samples.items()}} + report["rows"].append(row) + (out / "results.json").write_text(json.dumps(report, indent=2) + "\n") + medians = {name: round(v["median"], 4) for name, v in row["timings_ms"].items()} + print(distribution, kind, medians, flush=True) +print("Results:", out / "results.json", flush=True) diff --git a/changelog.d/10044-adaptive-array-sort.md b/changelog.d/10044-adaptive-array-sort.md new file mode 100644 index 0000000000..6dcb45240d --- /dev/null +++ b/changelog.d/10044-adaptive-array-sort.md @@ -0,0 +1,12 @@ +Speed up generic comparator sorting and shared dynamic operations. Adaptive +sorting over rooted indices reduces comparisons and repeated GC bookkeeping; +compact property caches, numeric guards, primitive-string comparisons, and +closure dispatch reduce comparator overhead without recognizing comparator +bodies. Collection remains enabled, with regression coverage for actual +relocation during comparison, collection getters, and allocating write-back. + +On the recorded M1 Max run, the original 100,000-element scriptc #289 sort +measures 0.61 ms versus Node 1.68 ms (baseline Perry 90.97 ms). +Perry leads 24/25 measured cases. The PR includes raw samples, matching-build +hashes, full-output verification, semantic regressions, and a reproducible +harness in `benchmarks/array-sort/`. Results are local to this shared host. diff --git a/crates/perry-codegen/src/expr/compare.rs b/crates/perry-codegen/src/expr/compare.rs index 6899ceb2d4..c5fe80ae44 100644 --- a/crates/perry-codegen/src/expr/compare.rs +++ b/crates/perry-codegen/src/expr/compare.rs @@ -4,6 +4,9 @@ //! Pure mechanical move — match arm bodies are verbatim copies, called from //! `lower_expr`'s outer dispatch. +#[path = "compare_short_string.rs"] +mod short_string; + use anyhow::Result; use perry_hir::types::Type as HirType; use perry_hir::{CompareOp, Expr}; @@ -376,30 +379,25 @@ fn lower_string_literal_strict_eq( /// /// Returns an i64 holding `TAG_TRUE`/`TAG_FALSE` (or `js_eq`'s own tagged /// boolean), i.e. the same value the bare call produced. -/// Quiet-NaN prefix (`0x7FF8_0000_0000_0000`) shared by every Perry NaN-box -/// tag and by the canonical NaN itself. -const QNAN_PREFIX_I64: &str = "9221120237041090560"; - -/// `(bits & 0x7FF8…) != 0x7FF8…`: the operand is an ordinary IEEE double — -/// finite, ±Infinity, or a signaling-NaN pattern no Perry encoding occupies. -/// Every NaN-box tag (top-16 `0x7FF9`..=`0x7FFF`, sign-clear) and the quiet -/// NaN carry the prefix, so one mask+compare separates "plain number" from -/// "tagged or NaN" without decoding either side. Two plain numbers answer -/// every relational and (strict or loose) equality operator with the raw -/// `fcmp`; the helper keeps NaN, so the unordered edge never reaches the -/// inline predicate. -fn emit_is_plain_double(ctx: &mut FnCtx<'_>, bits: &str) -> String { - let blk = ctx.block(); - let masked = blk.and(I64, bits, QNAN_PREFIX_I64); - blk.icmp_ne(I64, &masked, QNAN_PREFIX_I64) +/// All unboxed IEEE doubles, including NaNs. Boxed values occupy the +/// positive signed suffix starting at SHORT_STRING_TAG; one signed compare +/// rejects it. The ordered fcmp predicates below already make NaN unequal +/// and unordered, so a NaN does not need the coercing helper. +fn emit_is_unboxed_number(ctx: &mut FnCtx<'_>, bits: &str) -> String { + ctx.block().icmp_slt( + I64, + bits, + &crate::nanbox::i64_literal(crate::nanbox::SHORT_STRING_TAG), + ) } /// Dynamic-operand comparison with an inline plain-number fast path. /// /// When both NaN-boxed operands are ordinary doubles the result is -/// `select(fcmp l, r, TAG_TRUE, TAG_FALSE)`; every other shape — -/// strings, BigInt, objects with `valueOf`/`toString`, null/undefined/boolean -/// coercions, NaN — takes `helper`, which owns the full ECMAScript semantics. +/// `select(fcmp l, r, TAG_TRUE, TAG_FALSE)`. Relational comparisons +/// also handle two heap strings directly, using checked short ASCII words +/// or the UTF-16 helper. Mixed values and objects with coercion hooks retain +/// `helper`, which owns the full ECMAScript semantics. /// `helper_takes_bits` selects the `(i64, i64) -> i64` helper ABI /// (`js_eq`, `js_loose_eq`) over the `(double, double) -> double` one /// (`js_rel_*`). Returns the NaN-boxed boolean as i64 bits. @@ -413,8 +411,8 @@ fn lower_dynamic_compare_bits( ) -> String { let l_bits = ctx.block().bitcast_double_to_i64(l); let r_bits = ctx.block().bitcast_double_to_i64(r); - let l_plain = emit_is_plain_double(ctx, &l_bits); - let r_plain = emit_is_plain_double(ctx, &r_bits); + let l_plain = emit_is_unboxed_number(ctx, &l_bits); + let r_plain = emit_is_unboxed_number(ctx, &r_bits); let both_plain = ctx.block().and(I1, &l_plain, &r_plain); let fast_idx = ctx.new_block("dyncmp.num"); @@ -438,6 +436,56 @@ fn lower_dynamic_compare_bits( ctx.block().br(&merge_l); ctx.current_block = slow_idx; + let mut string_incoming = None; + if !helper_takes_bits { + // Relational comparisons of two heap strings cannot run coercion + // hooks. Guard their representation here and call the shared UTF-16 + // comparator directly; mixed values and inline strings retain the + // complete relational helper below. + let l_tag = ctx.block().lshr(I64, &l_bits, "48"); + let r_tag = ctx.block().lshr(I64, &r_bits, "48"); + let l_string = ctx + .block() + .icmp_eq(I64, &l_tag, crate::nanbox::STRING_TAG_TOP16_I64); + let r_string = ctx + .block() + .icmp_eq(I64, &r_tag, crate::nanbox::STRING_TAG_TOP16_I64); + let tags_match = ctx.block().and(I1, &l_string, &r_string); + let a = ctx.block().and(I64, &l_bits, POINTER_MASK_I64); + let b = ctx.block().and(I64, &r_bits, POINTER_MASK_I64); + // Keep the boxed helper's legacy null-string view semantics. + let a_present = ctx.block().icmp_ne(I64, &a, "0"); + let b_present = ctx.block().icmp_ne(I64, &b, "0"); + let both_present = ctx.block().and(I1, &a_present, &b_present); + let both_strings = ctx.block().and(I1, &tags_match, &both_present); + let string_idx = ctx.new_block("dyncmp.string"); + let coerce_idx = ctx.new_block("dyncmp.coerce"); + let string_label = ctx.block_label(string_idx); + let coerce_label = ctx.block_label(coerce_idx); + ctx.block() + .cond_br(&both_strings, &string_label, &coerce_label); + + ctx.current_block = string_idx; + let order = short_string::heap_string_order(ctx, &a, &b); + let bit = match pred { + "olt" => ctx.block().icmp_slt(I32, &order, "0"), + "ole" => ctx.block().icmp_sle(I32, &order, "0"), + "ogt" => ctx.block().icmp_sgt(I32, &order, "0"), + "oge" => ctx.block().icmp_sge(I32, &order, "0"), + _ => unreachable!("non-relational helper uses the boxed-bits ABI"), + }; + let result = ctx.block().select( + I1, + &bit, + I64, + crate::nanbox::TAG_TRUE_I64, + crate::nanbox::TAG_FALSE_I64, + ); + let predecessor = ctx.block().label.clone(); + ctx.block().br(&merge_l); + string_incoming = Some((result, predecessor)); + ctx.current_block = coerce_idx; + } let slow_res = if helper_takes_bits { ctx.block() .call(I64, helper, &[(I64, &l_bits), (I64, &r_bits)]) @@ -451,8 +499,14 @@ fn lower_dynamic_compare_bits( ctx.block().br(&merge_l); ctx.current_block = merge_idx; - ctx.block() - .phi(I64, &[(&fast_res, &fast_pred), (&slow_res, &slow_pred)]) + let mut incoming = vec![ + (&fast_res[..], &fast_pred[..]), + (&slow_res[..], &slow_pred[..]), + ]; + if let Some((result, predecessor)) = &string_incoming { + incoming.push((&result[..], &predecessor[..])); + } + ctx.block().phi(I64, &incoming) } fn lower_strict_eq_inline_any(ctx: &mut FnCtx<'_>, l: &str, r: &str) -> String { @@ -500,8 +554,8 @@ fn lower_strict_eq_inline_any(ctx: &mut FnCtx<'_>, l: &str, r: &str) -> String { let tag_idx = ctx.new_block("anyeq.tag"); let num_l = ctx.block_label(num_idx); let tag_l = ctx.block_label(tag_idx); - let l_plain = emit_is_plain_double(ctx, &l_bits); - let r_plain = emit_is_plain_double(ctx, &r_bits); + let l_plain = emit_is_unboxed_number(ctx, &l_bits); + let r_plain = emit_is_unboxed_number(ctx, &r_bits); let both_plain = ctx.block().and(I1, &l_plain, &r_plain); ctx.block().cond_br(&both_plain, &num_l, &tag_l); diff --git a/crates/perry-codegen/src/expr/compare_short_string.rs b/crates/perry-codegen/src/expr/compare_short_string.rs new file mode 100644 index 0000000000..77f10965d8 --- /dev/null +++ b/crates/perry-codegen/src/expr/compare_short_string.rs @@ -0,0 +1,100 @@ +//! Word-sized ASCII ordering for generic heap-string relations. Bounds and +//! actual bytes are checked; all other strings retain the UTF-16 helper. +use super::{FnCtx, STRING_HEADER_BYTE_LEN_OFFSET, STRING_HEADER_SIZE}; +use crate::types::{I1, I32, I64, I8}; + +pub(super) fn heap_string_order(ctx: &mut FnCtx<'_>, a: &str, b: &str) -> String { + if !matches!( + ctx.target_triple.split('-').next().unwrap_or(""), + "aarch64" | "arm64" | "arm64_32" | "x86_64" | "i686" | "i386" | "riscv64" | "wasm32" + ) { + return ctx + .block() + .call(I32, "js_string_compare", &[(I64, a), (I64, b)]); + } + let header_idx = ctx.new_block("strord.header"); + let words_idx = ctx.new_block("strord.words"); + let ascii_idx = ctx.new_block("strord.ascii"); + let slow_idx = ctx.new_block("strord.utf16"); + let merge_idx = ctx.new_block("strord.merge"); + let header_l = ctx.block_label(header_idx); + let words_l = ctx.block_label(words_idx); + let ascii_l = ctx.block_label(ascii_idx); + let slow_l = ctx.block_label(slow_idx); + let merge_l = ctx.block_label(merge_idx); + let a_valid = ctx.block().icmp_ugt(I64, a, "4095"); + let b_valid = ctx.block().icmp_ugt(I64, b, "4095"); + let valid = ctx.block().and(I1, &a_valid, &b_valid); + ctx.block().cond_br(&valid, &header_l, &slow_l); + + ctx.current_block = header_idx; + let ap = ctx.block().inttoptr(I64, a); + let bp = ctx.block().inttoptr(I64, b); + let alenp = ctx + .block() + .gep_inbounds(I8, &ap, &[(I64, STRING_HEADER_BYTE_LEN_OFFSET)]); + let blenp = ctx + .block() + .gep_inbounds(I8, &bp, &[(I64, STRING_HEADER_BYTE_LEN_OFFSET)]); + let alen = ctx.block().load(I32, &alenp); + let blen = ctx.block().load(I32, &blenp); + let a_shorter = ctx.block().icmp_ult(I32, &alen, &blen); + let common = ctx.block().select(I1, &a_shorter, I32, &alen, &blen); + let tail = ctx.block().sub(I32, &common, "8"); + let in_range = ctx.block().icmp_ule(I32, &tail, "8"); + ctx.block().cond_br(&in_range, &words_l, &slow_l); + + ctx.current_block = words_idx; + // 8 <= common <= 16 proves both overlapping eight-byte loads are wholly + // inside both payloads. StringHeader is 20 bytes, so use alignment one. + let start = STRING_HEADER_SIZE.to_string(); + let tail64 = ctx.block().zext(I32, &tail, I64); + let last = ctx.block().add(I64, &tail64, &start); + let mut words = Vec::with_capacity(4); + for (ptr, offset) in [(&ap, &start), (&bp, &start), (&ap, &last), (&bp, &last)] { + let p = ctx.block().gep_inbounds(I8, ptr, &[(I64, offset)]); + words.push(ctx.block().load_aligned(I64, &p, 1)); + } + let first_bits = ctx.block().or(I64, &words[0], &words[1]); + let last_bits = ctx.block().or(I64, &words[2], &words[3]); + let all_bits = ctx.block().or(I64, &first_bits, &last_bits); + let high_bits = ctx.block().and(I64, &all_bits, "-9187201950435737472"); + let ascii = ctx.block().icmp_eq(I64, &high_bits, "0"); + ctx.block().cond_br(&ascii, &ascii_l, &slow_l); + + ctx.current_block = ascii_idx; + // Prefer the first unequal word. Its big-endian integer order is the + // byte order; ASCII bytes are exactly their UTF-16 code units. + let first_diff = ctx.block().icmp_ne(I64, &words[0], &words[1]); + let left = ctx + .block() + .select(I1, &first_diff, I64, &words[0], &words[2]); + let right = ctx + .block() + .select(I1, &first_diff, I64, &words[1], &words[3]); + let left = ctx.block().call(I64, "llvm.bswap.i64", &[(I64, &left)]); + let right = ctx.block().call(I64, "llvm.bswap.i64", &[(I64, &right)]); + let equal = ctx.block().icmp_eq(I64, &left, &right); + let byte_less = ctx.block().icmp_ult(I64, &left, &right); + let length_equal = ctx.block().icmp_eq(I32, &alen, &blen); + let length_greater = ctx.block().select(I1, &length_equal, I32, "0", "1"); + let length_order = ctx + .block() + .select(I1, &a_shorter, I32, "-1", &length_greater); + let byte_order = ctx.block().select(I1, &byte_less, I32, "-1", "1"); + let fast = ctx + .block() + .select(I1, &equal, I32, &length_order, &byte_order); + let fast_pred = ctx.block().label.clone(); + ctx.block().br(&merge_l); + + ctx.current_block = slow_idx; + let slow = ctx + .block() + .call(I32, "js_string_compare", &[(I64, a), (I64, b)]); + let slow_pred = ctx.block().label.clone(); + ctx.block().br(&merge_l); + ctx.current_block = merge_idx; + ctx.block() + .phi(I32, &[(&fast, &fast_pred), (&slow, &slow_pred)]) +} diff --git a/crates/perry-codegen/src/expr/compare_tests.rs b/crates/perry-codegen/src/expr/compare_tests.rs index 53df155181..da4b1f9041 100644 --- a/crates/perry-codegen/src/expr/compare_tests.rs +++ b/crates/perry-codegen/src/expr/compare_tests.rs @@ -71,6 +71,39 @@ fn cmp_ir(name: &str, op: CompareOp, lhs: Expr, rhs: Expr) -> String { const JS_EQ_CALL: &str = "call i64 @js_eq("; const JS_LOOSE_EQ_CALL: &str = "call i64 @js_loose_eq("; +#[test] +fn dynamic_relations_admit_unboxed_numbers_with_one_signed_guard_per_operand() { + for (op, helper, pred) in [ + (CompareOp::Lt, "js_rel_lt", "olt"), + (CompareOp::Le, "js_rel_le", "ole"), + (CompareOp::Gt, "js_rel_gt", "ogt"), + (CompareOp::Ge, "js_rel_ge", "oge"), + ] { + let ir = cmp_ir( + "dynamic_number_guard", + op, + Expr::LocalGet(X), + Expr::LocalGet(Y), + ); + assert!( + ir.contains("icmp slt i64"), + "unboxed-number guard was not emitted:\n{ir}" + ); + assert!( + ir.contains(&format!("fcmp {pred} double")), + "ordered NaN-aware comparison absent:\n{ir}" + ); + assert!( + ir.contains(&format!("call double @{helper}(")), + "coercing fallback absent:\n{ir}" + ); + assert!( + ir.contains("dyncmp.string") && ir.contains("call i32 @js_string_compare("), + "guarded heap-string comparison was not emitted:\n{ir}" + ); + } +} + #[test] fn strict_eq_against_a_proven_symbol_is_raw_identity() { let ir = ir_for( @@ -737,3 +770,37 @@ fn local_typeof_number_literal_is_decided_inline_for_plain_doubles() { "no typeof string or string equality may be materialized:\n{ir}" ); } + +#[test] +fn dynamic_string_order_checks_bounds_and_ascii_before_word_ordering() { + for op in [CompareOp::Lt, CompareOp::Le, CompareOp::Gt, CompareOp::Ge] { + let ir = cmp_ir( + "short_string_order", + op, + Expr::LocalGet(X), + Expr::LocalGet(Y), + ); + assert!( + ir.contains("strord.words") && ir.contains("strord.utf16"), + "{ir}" + ); + assert!( + ir.contains("icmp ule i32") && ir.contains("-9187201950435737472"), + "{ir}" + ); + assert!(ir.contains("call i64 @llvm.bswap.i64("), "{ir}"); + assert!( + ir.contains("call i32 @js_string_compare("), + "non-ASCII/other lengths retain UTF-16: {ir}" + ); + let words = super::class_field_barrier_tests::block_body(&ir, "strord.words.").unwrap(); + assert_eq!( + words + .lines() + .filter(|l| l.contains("load i64") && l.contains("align 1")) + .count(), + 4, + "{words}" + ); + } +} diff --git a/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs b/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs index 29040b9c65..28cb608ffe 100644 --- a/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs +++ b/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs @@ -321,16 +321,22 @@ pub(crate) fn lower_generic_property_get( ctx.current_block = not_string_idx; } - // Monomorphic inline cache. The per-site global holds an authoritative - // ShapeId token and its cached slot; word 2 optionally carries the proved - // Array-subclass named-prefix family token. - // The fast path compares the receiver's discriminated ShapeId token to - // cache[0] and, on match, loads - // the field directly at obj+ObjectHeader::SIZE+slot*8: no function call, no hash, - // no linear scan. On miss, calls the slow helper which does the - // full lookup and primes the cache for next time. + // A compact per-site word holds the exact ShapeId and slot for the last + // cacheable receiver. The lazily allocated full cache retains bounded + // polymorphic ways and the Array-subclass named-prefix proof. let cache_name = overridden_cache_name(ctx, object, property) .unwrap_or_else(|| allocate_property_cache(ctx)); + // A compact atomic MRU removes the cache-pointer dependency on a hit. + // The full cache stays lazy and serves prefix/overflow/polymorphic misses. + let packed_site = ctx.ic_site_counter; + ctx.ic_site_counter += 1; + let packed_name = format!( + "{}_packed_get", + crate::expr::inline_cache_global_name(ctx, packed_site) + ); + ctx.typed_parse_rodata + .push(format!("@{packed_name} = private global i64 0, align 8")); + let packed_ref = format!("@{packed_name}"); // Issue #72: validate the receiver is actually a GC_TYPE_OBJECT // before reading its ShapeId. The receiver @@ -419,14 +425,25 @@ pub(crate) fn lower_generic_property_get( ctx.block().cond_br(&is_real_ptr, &hdr_label, &cold_label); ctx.current_block = hdr_idx; - // GcHeader sits 8 bytes before the user pointer; obj_type is the - // first u8 (GC_TYPE_OBJECT=2). Cost: 1 sub + 1 load i8 + 1 cmp - // i8 + 1 and i1 — the cond_br's `is_object` operand is folded - // into the existing branch instruction by LLVM. Branch-predicted - // taken since real PropertyGet receivers are objects. + // The compact cache is a permanently valid scalar global. Load it before + // receiver-dependent shape probing so its latency overlaps header reads. + let packed_word = ctx.block().load_atomic_monotonic(I64, &packed_ref, 8); + let packed_present = ctx.block().icmp_ne(I64, &packed_word, "0"); + + // GcHeader starts with obj_type:u8, gc_flags:u8, reserved:u16. On + // known little-endian targets one load tests both kind and descriptors; + // other targets retain byte/halfword loads with native endianness. let gc_type_addr = ctx.block().sub(I64, &obj_handle, "8"); let gc_type_ptr = ctx.block().inttoptr(I64, &gc_type_addr); - let gc_type = ctx.block().load(I8, &gc_type_ptr); + let packed_header = matches!( + ctx.target_triple.split('-').next().unwrap_or(""), + "aarch64" | "arm64" | "arm64_32" | "x86_64" | "i686" | "i386" | "riscv64" | "wasm32" + ) + .then(|| ctx.block().load(I32, &gc_type_ptr)); + let gc_type = match &packed_header { + Some(word) => ctx.block().trunc(I32, word, I8), + None => ctx.block().load(I8, &gc_type_ptr), + }; // `MapHeader` and `SetHeader` both begin with `size: u32`. A native // collection is not an ObjectHeader and can never hit this PIC, so split @@ -457,45 +474,28 @@ pub(crate) fn lower_generic_property_get( // data property and `defineProperty` later converts that key to a getter // (or a different descriptor), `keys_array` is unchanged, so the stale // hit path would return the raw slot and bypass the getter entirely. - // OBJ_FLAG_HAS_DESCRIPTORS lives in the GcHeader `_reserved` i16 at - // offset -6; force a miss (→ `js_object_get_field_ic_miss`, which honors - // descriptors) whenever it is set. Mirrors the guard in - // `class_field_inline_guard.rs`. Cost: 1 sub + load i16 + and + cmp, - // folded into the existing `hit` cond_br. - let reserved_addr = ctx.block().sub(I64, &obj_handle, "6"); - let reserved_ptr = ctx.block().inttoptr(I64, &reserved_addr); - let reserved = ctx.block().load(crate::types::I16, &reserved_ptr); - let has_desc = ctx.block().and(crate::types::I16, &reserved, "2048"); // OBJ_FLAG_HAS_DESCRIPTORS (0x800) - let no_desc = ctx.block().icmp_eq(crate::types::I16, &has_desc, "0"); - // #9708: the site's cache lives behind a pointer slot that is null until - // the first priming miss. The slot load does not depend on the receiver, - // so it issues alongside the header loads, and its non-null test joins - // the flat header predicate as one more fused compare. Both edges that - // read a cache word (`pic.token` and the descriptor prefix path) require - // `cache_present`; the slot itself is what the miss handler takes, so a - // fresh site goes straight to it. `cache_ref` is the LOADED pointer from - // here on, never the global: every GEP below goes through it, and only - // the runtime calls take `cache_slot_ref`. - let ic_slot = crate::expr::emit_inline_cache_slot(ctx, &cache_name); - let cache_ref = ic_slot.cache.clone(); - let cache_slot_ref = ic_slot.slot_ref.clone(); - let cache_present = ic_slot.present.clone(); - let is_plain_object = ctx.block().and(I1, &is_object_kind, &no_desc); - let is_plain_object = ctx.block().and(I1, &is_plain_object, &cache_present); + // OBJ_FLAG_HAS_DESCRIPTORS is bit 11 of reserved (bit 27 of the + // little-endian header word). Ignore gc_flags and every other flag. + let is_plain_kind = if let Some(word) = &packed_header { + let kind_and_desc = ctx.block().and(I32, word, "134217983"); // 0x080000ff + ctx.block().icmp_eq(I32, &kind_and_desc, "2") + } else { + let reserved_addr = ctx.block().sub(I64, &obj_handle, "6"); + let reserved_ptr = ctx.block().inttoptr(I64, &reserved_addr); + let reserved = ctx.block().load(crate::types::I16, &reserved_ptr); + let has_desc = ctx.block().and(crate::types::I16, &reserved, "2048"); + let no_desc = ctx.block().icmp_eq(crate::types::I16, &has_desc, "0"); + ctx.block().and(I1, &is_object_kind, &no_desc) + }; + // Resolve the full cache only after the compact MRU declines a read. + // Loading it here keeps an unused global load on every successful hit. + // Each cold entry retains its own non-null proof before dereferencing. + let cache_slot_ref = format!("@{cache_name}"); + let is_plain_object = ctx.block().and(I1, &is_plain_kind, &packed_present); - // #7883: first exit. The header predicates above are kept as one flat - // `and` on purpose — they are loads from the same cache line and LLVM - // fuses their compares into a `ccmp` chain, which is - // cheaper than four branches. What was NOT worth folding is everything - // below: the ShapeId load and token select hang off the same predicate, - // so a non-object receiver used to execute - // them before the flat `hit` could reject it. - // - // #7907: the false edge goes to `pic.miss.cold`, not `pic.miss` — a - // receiver that is not a plain descriptor-free `ObjectHeader` fails - // `way_hit` by construction, so consulting the ways for it was always dead - // work, and keeping it out is what lets `pic.miss` reuse this block's - // values instead of re-deriving them. + // Validate kind, descriptor policy, and initialized MRU before reading + // ObjectHeader's ShapeId. Header failures bypass the shape ways; the + // descriptor-aware named-prefix path keeps its own full-cache guard. ctx.block() .cond_br(&is_plain_object, &tok_label, &desc_classify_label); @@ -505,9 +505,10 @@ pub(crate) fn lower_generic_property_get( // this classification off the ordinary descriptor-free hit path. ctx.current_block = desc_classify_idx; // #9708: the descriptor prefix path reads cache word 2, so it needs the - // same non-null proof `pic.token` has; without a cache the receiver is + // full-cache non-null proof as the shape-miss path; without a cache it is // simply a cold miss. - let desc_object_with_cache = ctx.block().and(I1, &is_object_kind, &cache_present); + let desc_cache = crate::expr::emit_inline_cache_slot(ctx, &cache_name); + let desc_object_with_cache = ctx.block().and(I1, &is_object_kind, &desc_cache.present); ctx.block().cond_br( &desc_object_with_cache, &desc_prefix_guard_label, @@ -522,60 +523,34 @@ pub(crate) fn lower_generic_property_get( let pcid_ptr = ctx.block().inttoptr(I64, &pcid_addr); let pcid = ctx.block().load(I32, &pcid_ptr); let pcid64 = ctx.block().zext(I32, &pcid, I64); - // PIC_ID_TOKEN_BIT = 1 << 62. The token is formed UNCONDITIONALLY — the - // in-range test the emitted code used to run first - // (`(pcid - 0x8000_0000) GcCallEffect { // invokes JavaScript getters, proxies, coercions, or callbacks. | "js_param_type_guard" | "js_is_truthy" + // string/compare.rs: immutable byte reads and bounded UTF-16 decoding; + // no allocation, runtime state access, coercion, or collector entry. + | "js_string_compare" | "js_typed_feedback_plain_array_index_get_guard" | "js_typed_feedback_numeric_array_index_get_guard" | "js_typed_feedback_plain_array_index_set_guard" @@ -729,7 +732,19 @@ mod tests { /// thing to reach for next; it is not admissible. #[test] fn allocating_helpers_are_not_cannot_collect() { - for name in ["js_nanbox_string", "js_string_from_bytes", "js_array_alloc"] { + assert_eq!( + classify_direct_callee("js_string_compare"), + GcCallEffect::CannotCollect + ); + for name in [ + "js_nanbox_string", + "js_string_from_bytes", + "js_array_alloc", + "js_string_compare_value", + "js_rel_lt", + "js_rel_gt", + "js_object_get_field_ic_miss_packed", + ] { assert_ne!( classify_direct_callee(name), GcCallEffect::CannotCollect, diff --git a/crates/perry-codegen/src/module.rs b/crates/perry-codegen/src/module.rs index 74807711eb..bb8ddd2cf7 100644 --- a/crates/perry-codegen/src/module.rs +++ b/crates/perry-codegen/src/module.rs @@ -1651,6 +1651,8 @@ mod tests { let mut m = LlModule::new("arm64-apple-macosx15.0.0"); m.declare_function("js_nanbox_get_pointer", I64, &[DOUBLE]); m.declare_function("js_is_truthy", I32, &[DOUBLE]); + m.declare_function("js_string_compare", I32, &[I64, I64]); + m.declare_function("js_string_compare_value", I32, &[DOUBLE, DOUBLE]); m.declare_function("js_nanbox_string", DOUBLE, &[I64]); m.declare_function( "js_typed_feedback_numeric_array_index_get_guard", @@ -1673,6 +1675,8 @@ mod tests { ir.contains("declare double @js_nanbox_string(i64)\n"), "allocating helper must stay attribute-free" ); + assert!(ir.contains("declare i32 @js_string_compare(i64, i64) #3")); + assert!(ir.contains("declare i32 @js_string_compare_value(double, double)\n")); assert!(!ir.contains("js_nanbox_string(i64) #")); assert_eq!( ir.matches("attributes #2 = { nounwind willreturn readnone }") diff --git a/crates/perry-codegen/src/module/linkage.rs b/crates/perry-codegen/src/module/linkage.rs index e1593b5ac8..531516b8ef 100644 --- a/crates/perry-codegen/src/module/linkage.rs +++ b/crates/perry-codegen/src/module/linkage.rs @@ -247,6 +247,11 @@ pub(crate) fn external_decl_for_global(line: &str) -> Option { /// a lock acquisition writes memory). pub(crate) fn helper_decl_attrs(name: &str) -> &'static str { match name { + // These calls sit behind cache-hit / inactive-marking guards. Keep + // register saves and code layout focused on the inline continuation. + // `cold` is only a profitability hint: both calls remain fully + // memory-clobbering and the cache miss remains GC-capable/throwing. + "js_object_get_field_ic_miss_packed" | "js_write_barrier_root_nanbox" => " cold", // PURE — each verified: pure bit tests/masking on the f64/i64 args, // total over arbitrary bits, no memory access anywhere in the body. // js_nanbox_pointer value/nanbox.rs — tag ladder, 0 → TAG_NULL @@ -270,6 +275,12 @@ pub(crate) fn helper_decl_attrs(name: &str) -> &'static str { // (js_bigint_is_zero via clean_bigint_ptr, pure bit cleanup). No // registry/lock access, no allocation, no throw, no writes. "js_is_truthy" => " #3", + // string/compare.rs: pointer magnitude guards, immutable byte views, + // bounded word scans / UTF-16 decoder iteration only. No allocation, + // GC, locks, writes, or JavaScript coercion. Read-any (not argmem: + // operands are i64 handles) orders it against every GC-capable call. + // js_string_compare_value is NOT eligible: number coercion allocates. + "js_string_compare" => " #3", // NOUNWIND+WILLRETURN only (#4, repsel Phase 4a.0) — each verified // (`typed_feedback.rs` / `array/header.rs`): no `js_throw` (longjmp) // anywhere in the body, every loop bounded by the 16M length/capacity diff --git a/crates/perry-codegen/src/root_reload.rs b/crates/perry-codegen/src/root_reload.rs index efc23f6f5f..bae3c1132f 100644 --- a/crates/perry-codegen/src/root_reload.rs +++ b/crates/perry-codegen/src/root_reload.rs @@ -217,6 +217,8 @@ const NON_COLLECTING: &[&str] = &[ // made a REAL transposition indistinguishable from an aspirational entry. // `every_non_collecting_entry_is_a_real_runtime_export` now rejects both. "js_is_truthy", + // string/compare.rs: pointer guards, byte reads, bounded decoding only. + "js_string_compare", "js_nanbox_get_pointer", // inline-cache guards: pure reads "js_typed_feedback_closure_direct_call_guard", diff --git a/crates/perry-codegen/src/root_reload_tests.rs b/crates/perry-codegen/src/root_reload_tests.rs index 28b192c378..1b04b7c2b0 100644 --- a/crates/perry-codegen/src/root_reload_tests.rs +++ b/crates/perry-codegen/src/root_reload_tests.rs @@ -722,10 +722,12 @@ fn the_masked_receiver_is_re_derived_not_just_the_load() { /// mask in the program into three extra instructions. #[test] fn a_masked_receiver_with_no_collection_point_is_left_alone() { - let mut f = masked_receiver("js_is_truthy"); - let before = body(&f); - assert_eq!(apply_to_function(&mut f), 0); - assert_eq!(body(&f), before); + for helper in ["js_is_truthy", "js_string_compare"] { + let mut f = masked_receiver(helper); + let before = body(&f); + assert_eq!(apply_to_function(&mut f), 0); + assert_eq!(body(&f), before); + } } /// A derivation is only extended through PURE ops. A call in the middle of diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index e1c704af85..52f88598c1 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -356,6 +356,11 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { module.declare_function("js_nm_install_zlib", VOID, &[]); module.declare_function("js_nm_install_all", VOID, &[]); module.declare_function("js_object_get_field_ic_miss", DOUBLE, &[I64, I64, PTR]); + module.declare_function( + "js_object_get_field_ic_miss_packed", + DOUBLE, + &[I64, I64, PTR, PTR], + ); // #5391 path 3: full-outlined generic property GET. Collapses the inline // receiver-routing + monomorphic-IC + feedback + nullish-throw diamond to a // single call for oversized modules. Args: (obj_bits, key_handle, site_id, diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index 52afd86ef2..bb3da4296d 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -6681,22 +6681,16 @@ fn dynamic_bound_private_counter_is_safe( pub(crate) fn emit_js_value_is_number(ctx: &mut FnCtx<'_>, value: &str) -> String { let n_bits = ctx.block().bitcast_double_to_i64(value); - let tag = ctx.block().and( + // Every boxed tag occupies the positive suffix [0x7FF9_0000_0000_0000, + // 0x7FFF_FFFF_FFFF_FFFF]. A signed comparison rejects that whole suffix + // while admitting every negative IEEE value, including negative NaNs. + // INT32 boxes remain excluded: their payload still needs unboxing before + // floating-point arithmetic. This is exactly JSValue::is_number's range. + ctx.block().icmp_slt( I64, &n_bits, - &crate::nanbox::i64_literal(crate::nanbox::TAG_MASK), - ); - let below = ctx.block().icmp_ult( - I64, - &tag, &crate::nanbox::i64_literal(crate::nanbox::SHORT_STRING_TAG), - ); - let above = ctx.block().icmp_ugt( - I64, - &tag, - &crate::nanbox::i64_literal(crate::nanbox::STRING_TAG), - ); - ctx.block().or(I1, &below, &above) + ) } /// For-loop lowering: classic init / cond / body / update / exit CFG. diff --git a/crates/perry-runtime/src/array/generic_object.rs b/crates/perry-runtime/src/array/generic_object.rs index defb073f74..7fac83396a 100644 --- a/crates/perry-runtime/src/array/generic_object.rs +++ b/crates/perry-runtime/src/array/generic_object.rs @@ -369,20 +369,22 @@ pub(crate) fn object_splice(recv: f64, args_ptr: *const f64, args_len: usize) -> /// via `Set` and `Delete` the trailing range. Returns the receiver. /// `cmp_validated` is the already-validated comparator (null = default sort). pub(crate) fn object_sort(recv: f64, cmp_validated: *const ClosureHeader) -> f64 { + // Length and indexed getters may collect before comparison begins. + let scope = crate::gc::RuntimeHandleScope::new(); + let recv_handle = scope.root_nanbox_f64(recv); + let cmp_handle = scope.root_raw_const_ptr(cmp_validated); let cmp = if cmp_validated.is_null() { None } else { Some(super::sort::ComparatorCall::new(cmp_validated)) }; - let len = al_length(recv); + let len = al_length(recv_handle.get_nanbox_f64()); unsafe { // Root BOTH the receiver value and the collection temp for the whole // protocol: `al_has`/`al_get`/`al_set` fire user accessors (and the // comparator runs inside `sort_rooted_values`) — any of them can // allocate and sweep or move either object, so every raw pointer is // re-derived from its rooted handle after each such call. - let scope = crate::gc::RuntimeHandleScope::new(); - let recv_handle = scope.root_nanbox_f64(recv); let temp = super::sort::RootedArrayElems::new( &scope, js_array_alloc_with_length(len.clamp(0, u32::MAX as i64) as u32), @@ -402,6 +404,7 @@ pub(crate) fn object_sort(recv: f64, cmp_validated: *const ClosureHeader) -> f64 } (*temp.arr()).length = count as u32; rebuild_array_layout(temp.arr()); + let cmp = cmp.map(|c| c.current(&cmp_handle)); let _ = super::sort::sort_rooted_values(temp.arr(), count, cmp); for j in 0..count { al_set(recv_handle.get_nanbox_f64(), j as i64, temp.get(j)); diff --git a/crates/perry-runtime/src/array/header.rs b/crates/perry-runtime/src/array/header.rs index d23aed1228..361a53730c 100644 --- a/crates/perry-runtime/src/array/header.rs +++ b/crates/perry-runtime/src/array/header.rs @@ -1762,9 +1762,11 @@ pub(crate) unsafe fn refresh_array_numeric_layout(arr: *mut ArrayHeader) { /// [`refresh_array_numeric_layout`] for a head the caller already resolved. #[inline] pub(crate) unsafe fn refresh_array_numeric_layout_resolved(arr: *mut ArrayHeader) { - if array_slots_are_numeric(arr) { - rebuild_array_numeric_raw_f64(arr); - } else { + // Canonicalization already validates every slot. Avoid a separate full + // validation pass. A failing mixed payload may have had numeric boxes in + // its prefix canonicalized, which preserves their JS values; it must + // still lose both numeric-layout claims (including on a hole). + if !rebuild_array_numeric_raw_f64(arr) { clear_array_numeric_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 327a6fffd4..8aa0df040f 100644 --- a/crates/perry-runtime/src/array/header_gc_slots.rs +++ b/crates/perry-runtime/src/array/header_gc_slots.rs @@ -199,7 +199,28 @@ pub(crate) unsafe fn rebuild_array_layout(arr: *mut ArrayHeader) { crate::gc::layout_init_all_pointer_slots(arr as *mut u8); return; } - crate::gc::layout_rebuild_from_slots(arr as *mut u8, array_elements_ptr(arr), length); + if length != 0 { + // Numeric canonicalization proves the complete payload pointer-free + // and installs that GC layout itself. Do it before allocating and + // building a pointer mask, and avoid replaying barriers for numbers. + // This reads the actual slots; bulk stores may have invalidated the + // array's previous representation or introduced a class-ref tag. + super::header::refresh_array_numeric_layout_resolved(arr); + if array_has_raw_f64_layout_flag(arr) { + return; + } + } + if length != 0 + && crate::gc::layout_all_pointer_slots_would_hold(array_elements_ptr(arr), length) + { + // A homogeneous pointer payload needs only the existing all-pointer + // header claim, not an allocated bit mask with every bit set. The + // predicate reads every actual slot, including after arbitrary bulk + // stores; no previous layout claim is trusted here. + crate::gc::layout_init_all_pointer_slots(arr as *mut u8); + } else { + crate::gc::layout_rebuild_from_slots(arr as *mut u8, array_elements_ptr(arr), length); + } if length == 0 { // `layout_rebuild_from_slots` just left the head POINTER_FREE with its // per-object records dropped and the typed-intact bit cleared, which @@ -225,13 +246,14 @@ pub(crate) unsafe fn rebuild_array_layout(arr: *mut ArrayHeader) { } return; } - super::header::refresh_array_numeric_layout_resolved(arr); if crate::arena::pointer_in_old_gen(arr as usize) { - let slots = array_elements_ptr(arr); - for i in 0..length { - let slot = slots.add(i); - crate::gc::runtime_write_barrier_slot(arr as usize, slot as usize, *slot); - } + // The range barrier keeps incremental shading and records old→young + // edges page by page, hoisting the invariant parent checks. + crate::gc::replay_old_parent_slot_range_barriers( + arr as usize, + array_elements_ptr(arr), + length, + ); } } diff --git a/crates/perry-runtime/src/array/push_pop.rs b/crates/perry-runtime/src/array/push_pop.rs index ae5306a8c2..63e91b010b 100644 --- a/crates/perry-runtime/src/array/push_pop.rs +++ b/crates/perry-runtime/src/array/push_pop.rs @@ -41,7 +41,7 @@ fn throw_non_writable_length() -> ! { } #[cold] -fn throw_cannot_delete_array_index(index: u32) -> ! { +pub(super) fn throw_cannot_delete_array_index(index: u32) -> ! { crate::collection_iter::throw_type_error(&format!( "Cannot delete property '{index}' of [object Array]" )); diff --git a/crates/perry-runtime/src/array/sort.rs b/crates/perry-runtime/src/array/sort.rs index 8d7b410c38..994cd44857 100644 --- a/crates/perry-runtime/src/array/sort.rs +++ b/crates/perry-runtime/src/array/sort.rs @@ -3,6 +3,9 @@ use super::*; use crate::closure::ClosureHeader; +#[path = "sort_indices.rs"] +mod indices; + // --------------------------------------------------------------------------- // SortCompare helpers shared by the dense fast paths, the exotic spec path, // and the generic array-like engine (`object_sort`). @@ -24,32 +27,33 @@ impl ComparatorCall { } } - /// ECMA-262 `CompareArrayElements` numeric result: `ToNumber(Call(...))` - /// with NaN → +0. A plain finite/±inf f64 result skips the coercion; any - /// NaN-boxed value (boolean, string, object with `valueOf`, undefined) - /// goes through real ToNumber (firing user `valueOf`, throwing on - /// BigInt/Symbol per spec). + /// Refresh immediately before handing off to another rooted sort phase. + /// Collection getters can relocate the closure before comparison starts. + pub(crate) fn current(self, handle: &crate::gc::RuntimeHandle<'_>) -> Self { + handle.with_const_ptr(|comparator| Self { comparator, ..self }) + } + + /// Stable merge predicate for `CompareArrayElements`: ToNumber of the + /// callback result, with NaN treated as equality. Ordered comparisons + /// classify every ordinary number using one set of floating-point flags; + /// only an unordered (NaN or boxed) result needs coercion. /// - /// Takes an explicitly re-derived closure header rather than using the - /// one cached in `self`: the sorting engines root the comparator in a - /// `RuntimeHandleScope` and pass the CURRENT address here after every - /// user-code window — a comparator that allocates can trigger a moving - /// minor GC that relocates its own closure header, and the raw pointer - /// cached in `self.comparator` then points at from-space. `direct` stays - /// valid: it is a static code address resolved from the closure's shape, - /// which relocation does not change. + /// The closure address is re-read from its root before each callback; + /// `direct` is a static code address and remains valid across relocation. #[inline(always)] - pub(crate) fn compare_at(&self, comparator: *const ClosureHeader, a: f64, b: f64) -> f64 { + pub(crate) fn less_equal_at(&self, comparator: *const ClosureHeader, a: f64, b: f64) -> bool { let r = self.direct.call(comparator, a, b); - if !r.is_nan() { - return r; + if r <= 0.0 { + return true; } - let n = crate::builtins::js_number_coerce(r); - if n.is_nan() { - 0.0 - } else { - n + if r > 0.0 { + return false; } + // Unary plus implements abstract ToNumber, including rejection of + // BigInt returned directly or by an object's coercion hook. Number() + // deliberately accepts BigInt and is not the sort coercion contract. + let n = unsafe { crate::value::js_dynamic_pos(r) }; + n <= 0.0 || n.is_nan() } } @@ -76,11 +80,6 @@ fn is_undefined_bits(bits: u64) -> bool { bits == crate::value::TAG_UNDEFINED } -/// TimSort-style hybrid threshold shared by the sorting engines below: -/// insertion sort for runs of at most this many elements, bottom-up merges -/// above it. -const INSERTION_THRESHOLD: usize = 32; - /// GC-rooted view of an array's inline element storage. The header pointer /// lives in a `RuntimeHandleScope` slot (marked AND rewritten by a moving /// collection), and the element base is re-derived from the CURRENT header @@ -122,121 +121,117 @@ impl<'s> RootedArrayElems<'s> { } } -/// In-place insertion sort of `vals[start..end]` under `le(a, b)` ("a sorts -/// at-or-before b"). Swap-based: the moving key is never parked in a Rust -/// local across a comparator call, so the authoritative element bits always -/// live in the rooted array and get rewritten in place by a moving GC. -unsafe fn insertion_sort_rooted( - vals: &RootedArrayElems<'_>, - start: usize, - end: usize, - le: &mut impl FnMut(f64, f64) -> bool, -) { - for i in (start + 1)..end { - let mut j = i; - while j > start { - // `le` is user code — both operands are re-read AFTER it returns - // (their slots were rewritten in place if the array moved). - if le(vals.get(j - 1), vals.get(j)) { - break; - } - let prev = vals.get(j - 1); - let cur = vals.get(j); - vals.set(j - 1, cur); - vals.set(j, prev); - j -= 1; - } - } -} - -/// Stable bottom-up merge sort over TWO rooted element buffers: `data` holds -/// the values, `scratch` is the ping-pong buffer, and runs of `start_width` -/// are assumed already sorted. Tolerant of an inconsistent user comparator -/// (never panics, unlike `slice::sort_by`). Every element access re-derives -/// the buffer base from its rooted handle and re-reads the winning element -/// AFTER each comparator call — the authoritative bits always live in one of -/// the two GC-visible arrays, never in an unrooted Rust buffer. (The #6076 -/// merge engine ping-ponged through a bare `Vec`: after the first src/dst -/// swap the authoritative bits lived in that Vec, and a moving minor during a -/// comparator call left pre-move addresses in the published result.) -unsafe fn stable_merge_sort_rooted( +/// Sort indices into the rooted source instead of moving GC values during +/// comparisons. The source is private to this sort, so callbacks can move its +/// objects but cannot mutate its slots. No copied heap value survives a call. +unsafe fn with_sorted_indices( data: &RootedArrayElems<'_>, - scratch: &RootedArrayElems<'_>, n: usize, - start_width: usize, - mut le: impl FnMut(f64, f64) -> bool, -) { - if n <= 1 { - return; - } - let mut in_data = true; // which buffer currently holds the runs - let mut width = start_width.max(1); - while width < n { - let (src, dst) = if in_data { - (data, scratch) - } else { - (scratch, data) - }; - let mut i = 0usize; - while i < n { - let left = i; - let mid = (i + width).min(n); - let right = (i + 2 * width).min(n); - let (mut l, mut r, mut k) = (left, mid, left); - while l < mid && r < right { - if le(src.get(l), src.get(r)) { - dst.set(k, src.get(l)); - l += 1; - } else { - dst.set(k, src.get(r)); - r += 1; - } - k += 1; - } - while l < mid { - dst.set(k, src.get(l)); - l += 1; - k += 1; - } - while r < right { - dst.set(k, src.get(r)); - r += 1; - k += 1; - } - i += 2 * width; - } - in_data = !in_data; - width *= 2; - } - if !in_data { - // Final runs landed in scratch — copy back (no user code here). - for i in 0..n { - data.set(i, scratch.get(i)); - } - } + c: &ComparatorCall, + cmp_handle: &crate::gc::RuntimeHandle<'_>, + apply: impl FnOnce(&mut [u32]) -> R, +) -> R { + const STACK_INDICES: usize = 64; + if n <= STACK_INDICES { + let mut order = [0u32; STACK_INDICES]; + let mut scratch = [0u32; STACK_INDICES]; + sort_permutation(data, &mut order[..n], &mut scratch[..n], c, cmp_handle); + return apply(&mut order[..n]); + } + + // JS throws bypass Rust destructors. GC-owned workspaces are reclaimed + // even on that path; a Vec allocated before the callback would leak. + // TypedArray payloads are pointer-free and NON-MOVABLE, so these private + // slices remain valid across arbitrary callbacks and copying collections. + // Keep both allocations rooted, including across allocation of the second. + let scope = crate::gc::RuntimeHandleScope::new(); + let order_handle = scope.root_raw_mut_ptr(crate::typedarray::typed_array_alloc( + crate::typedarray::KIND_UINT32, + n as u32, + )); + let scratch_handle = scope.root_raw_mut_ptr(crate::typedarray::typed_array_alloc( + crate::typedarray::KIND_UINT32, + n as u32, + )); + let order = std::slice::from_raw_parts_mut( + order_handle.with_mut_ptr(crate::typedarray::data_ptr_mut) as *mut u32, + n, + ); + let scratch = std::slice::from_raw_parts_mut( + scratch_handle.with_mut_ptr(crate::typedarray::data_ptr_mut) as *mut u32, + n, + ); + sort_permutation(data, order, scratch, c, cmp_handle); + apply(order) } -/// Comparator sort of `data[0..n]` (hybrid insertion + merge) with the -/// comparator header re-derived from `cmp_handle` before every call. -unsafe fn sort_comparator_rooted( +unsafe fn sort_permutation( data: &RootedArrayElems<'_>, - scratch: &RootedArrayElems<'_>, - n: usize, + order: &mut [u32], + scratch: &mut [u32], c: &ComparatorCall, cmp_handle: &crate::gc::RuntimeHandle<'_>, ) { - let mut le = |a: f64, b: f64| -> bool { - c.compare_at(cmp_handle.get_raw_const_ptr::(), a, b) <= 0.0 - }; - // Phase 1: insertion-sort each small run in place. - let mut run_start = 0usize; - while run_start < n { - let run_end = (run_start + INSERTION_THRESHOLD).min(n); - insertion_sort_rooted(data, run_start, run_end, &mut le); - run_start = run_end; - } - // Phase 2: bottom-up merges, ping-ponging between the two rooted buffers. - stable_merge_sort_rooted(data, scratch, n, INSERTION_THRESHOLD, le); + for (i, index) in order.iter_mut().enumerate() { + *index = i as u32; + } + crate::gc::with_stack_roots( + [ + data.arr() as u64, + cmp_handle.get_raw_const_ptr::() as u64, + ], + |roots| { + indices::sort_indices( + order, + scratch, + #[inline(always)] + |a, b| { + // Each slot is bound to the shadow stack and rewritten by + // moving GC. Read both anew after every user-code window; + // no interior pointer or copied value survives a callback. + let arr = roots.get(0) as *const ArrayHeader; + let comparator = roots.get(1) as *const ClosureHeader; + let elements = + (arr as *const u8).add(std::mem::size_of::()) as *const f64; + c.less_equal_at( + comparator, + *elements.add(a as usize), + *elements.add(b as usize), + ) + }, + ); + }, + ); +} + +unsafe fn apply_sorted_indices(data: &RootedArrayElems<'_>, order: &mut [u32]) { + // No user code or GC allocation below. Apply the permutation by cycles, + // then rebuild the layout and remembered edges once, as in sort's dense + // receiver write-back. This replaces O(n log n) barriered element writes + // with O(n) writes, without suppressing any collection in the comparator. + let arr = data.arr(); + let elements = (arr as *mut u8).add(std::mem::size_of::()) as *mut f64; + mark_array_layout_unknown(arr); + for start in 0..order.len() { + if order[start] as usize == start { + continue; + } + let saved = *elements.add(start); + let mut dest = start; + loop { + let source = order[dest] as usize; + order[dest] = dest as u32; + if source == start { + // GC_STORE_AUDIT(BARRIERED): permutation stores are followed by the rebuild below, with no intervening safepoint. + *elements.add(dest) = saved; + break; + } + // GC_STORE_AUDIT(BARRIERED): same callback-free permutation region. + *elements.add(dest) = *elements.add(source); + dest = source; + } + } + rebuild_array_layout(arr); } /// Default (no-comparator) SortCompare over a rooted buffer: ToString each @@ -284,8 +279,9 @@ pub(crate) unsafe fn sort_rooted_values( match cmp { Some(c) => { let cmp_handle = scope.root_raw_const_ptr(c.comparator); - let scratch = RootedArrayElems::new(&scope, js_array_alloc_with_length(count as u32)); - sort_comparator_rooted(&data, &scratch, count, &c, &cmp_handle); + with_sorted_indices(&data, count, &c, &cmp_handle, |order| { + apply_sorted_indices(&data, order); + }); } None => sort_default_rooted(&data, count), } @@ -321,6 +317,13 @@ fn object_prototype_value() -> Option { /// Own array-index keys of `Object.prototype` (usually empty). Computed once /// per sort call; the result gates the per-index inherited reads below. fn object_prototype_numeric_keys() -> Vec { + // The shared write/defineProperty latch is false until an indexed own + // property can exist. Consult it before resolving the builtin Object: + // doing that unconditionally bootstraps all global builtins on the first + // sort, even when this program has never touched Object.prototype. + if !crate::array::object_prototype_has_index_flag() { + return Vec::new(); + } let Some(proto) = object_prototype_value() else { return Vec::new(); }; @@ -438,6 +441,10 @@ unsafe fn sort_spec_set( ), value_handle.get_nanbox_f64(), ); + } else { + crate::collection_iter::throw_type_error(&format!( + "Cannot set property {index} which has only a getter" + )); } return; } @@ -451,6 +458,89 @@ unsafe fn sort_spec_set( arr_handle.set_raw_mut_ptr(updated); } +unsafe fn sort_spec_delete(receiver: &crate::gc::RuntimeHandle<'_>, index: u32) { + let (deleted, _) = receiver.across_mut::(|| { + receiver.with_mut_ptr(|arr| crate::array::js_array_delete(arr, index)) + }); + if deleted == 0 { + super::push_pop::throw_cannot_delete_array_index(index); + } +} + +/// Publish the private sorted snapshot. A comparator may have grown or +/// truncated the receiver, changed its descriptors, or modified its prototype. +/// Resolve forwarding and recheck the store protocol after the last callback. +unsafe fn publish_sorted_values( + receiver: &crate::gc::RuntimeHandle<'_>, + values: &RootedArrayElems<'_>, + count: usize, + undefined_count: usize, + original_length: usize, + order: Option<&[u32]>, +) -> *mut ArrayHeader { + let arr = receiver.with_mut_ptr(clean_arr_ptr_mut); + receiver.set_raw_mut_ptr(arr); + let flags = array_object_flags_resolved(arr); + if (*arr).length as usize >= original_length + && (*arr).capacity as usize >= original_length + && flags + & (crate::gc::OBJ_FLAG_FROZEN + | crate::gc::OBJ_FLAG_SEALED + | crate::gc::OBJ_FLAG_NO_EXTEND) + == 0 + && !super::indexing::array_iteration_is_exotic_resolved(arr, flags) + { + // No user code or allocation in this region. Resolve each root once, + // copy the dense prefix, and rebuild layout/barriers after all stores. + let source = + (values.arr() as *const u8).add(std::mem::size_of::()) as *const f64; + let dest = (arr as *mut u8).add(std::mem::size_of::()) as *mut f64; + mark_array_layout_unknown(arr); + if let Some(order) = order { + debug_assert_eq!(order.len(), count); + for (i, &index) in order.iter().enumerate() { + // GC_STORE_AUDIT(BARRIERED): the rebuild below covers these indexed stores, with no intervening safepoint. + *dest.add(i) = *source.add(index as usize); + } + } else { + // GC_STORE_AUDIT(BARRIERED): the bulk copy is covered by the rebuild below. + std::ptr::copy_nonoverlapping(source, dest, count); + } + for i in count..count + undefined_count { + // GC_STORE_AUDIT(POINTER_FREE): undefined has no child edge. + *dest.add(i) = f64::from_bits(crate::value::TAG_UNDEFINED); + } + for i in count + undefined_count..original_length { + // GC_STORE_AUDIT(POINTER_FREE): a deleted slot has no child edge. + *dest.add(i) = f64::from_bits(crate::value::TAG_HOLE); + } + rebuild_array_layout(arr); + return arr; + } + + let prototype_keys = object_prototype_numeric_keys(); + for i in 0..count { + sort_spec_set( + receiver, + i as u32, + values.get(order.map_or(i, |indices| indices[i] as usize)), + &prototype_keys, + ); + } + for i in count..count + undefined_count { + sort_spec_set( + receiver, + i as u32, + f64::from_bits(crate::value::TAG_UNDEFINED), + &prototype_keys, + ); + } + for i in count + undefined_count..original_length { + sort_spec_delete(receiver, i as u32); + } + receiver.get_raw_mut_ptr::() +} + // --------------------------------------------------------------------------- // Spec-ops sort path for a real-array receiver (ECMA-262 §23.1.3.30 // SortIndexedProperties with holes skipped): collect via [[HasProperty]] / @@ -471,6 +561,7 @@ unsafe fn array_sort_spec_path( // [[Set]] fire user accessors that can allocate, sweeping or moving it. let scope = crate::gc::RuntimeHandleScope::new(); let arr_handle = scope.root_raw_mut_ptr(arr); + let cmp_handle = cmp.map(|c| scope.root_raw_const_ptr(c.comparator)); // Collect present elements into a GC-rooted temp array whose element // buffer keeps accessor-produced values alive — and CURRENT — across @@ -504,6 +595,7 @@ unsafe fn array_sort_spec_path( let item_count = count + undef_count; // Sort the defined values; the scratch buffer is a second rooted array. + let cmp = cmp.map(|c| c.current(cmp_handle.as_ref().unwrap())); let _ = sort_rooted_values(temp.arr(), count, cmp); // Write back via [[Set]] (fires index setters / honors attrs), then @@ -523,7 +615,7 @@ unsafe fn array_sort_spec_path( ); } for j in item_count..len as usize { - crate::array::js_array_delete(arr_handle.get_raw_mut_ptr::(), j as u32); + sort_spec_delete(&arr_handle, j as u32); } arr_handle.get_raw_mut_ptr::() } @@ -557,8 +649,8 @@ pub extern "C" fn js_array_sort_default(arr: *mut ArrayHeader) -> *mut ArrayHead // Probe the RAW pointer BEFORE the array-plausibility clean (which // may NULL an object receiver out, silently no-op'ing the sort). if let Some(recv) = crate::array::non_array_object_receiver(arr) { - crate::array::object_sort(recv, std::ptr::null()); - return arr; + let sorted = crate::array::object_sort(recv, std::ptr::null()); + return (sorted.to_bits() & crate::value::POINTER_MASK) as *mut ArrayHeader; } // Issue #654: route typed-array receivers (compiler statically typed // `arr` as `Float64Array | Int32Array | …` and emitted the ArraySort @@ -680,8 +772,8 @@ pub extern "C" fn js_array_sort_with_comparator( // Runtime plain-object receiver behind a statically-Array variable — // probe the RAW pointer before the array-plausibility clean. if let Some(recv) = crate::array::non_array_object_receiver(arr) { - crate::array::object_sort(recv, comparator); - return arr; + let sorted = crate::array::object_sort(recv, comparator); + return (sorted.to_bits() & crate::value::POINTER_MASK) as *mut ArrayHeader; } // Issue #654 / #8096: same routing as `js_array_sort_default`, and // asked at the same point — before `clean_arr_ptr` rejects the @@ -710,9 +802,11 @@ unsafe fn sort_array_receiver( // it again, and a bare Rust local is invisible to the collector. let scope = crate::gc::RuntimeHandleScope::new(); let arr_handle = scope.root_raw_mut_ptr(arr); + let cmp_handle = cmp.map(|c| scope.root_raw_const_ptr(c.comparator)); let objproto_keys = object_prototype_numeric_keys(); let arr = arr_handle.get_raw_mut_ptr::(); if sort_needs_spec_path(arr, &objproto_keys) { + let cmp = cmp.map(|c| c.current(cmp_handle.as_ref().unwrap())); return array_sort_spec_path(arr, cmp, &objproto_keys); } @@ -764,31 +858,10 @@ unsafe fn sort_array_receiver( } (*temp.arr()).length = count as u32; rebuild_array_layout(temp.arr()); + let cmp = cmp.map(|c| c.current(cmp_handle.as_ref().unwrap())); let _ = sort_rooted_values(temp.arr(), count, cmp); - // Write back (no user code below): sorted defined values, then - // `undefined` ×N, then holes ×N — restoring the exotic sparseness. - let arr = arr_handle.get_raw_mut_ptr::(); - let elements_ptr = (arr as *mut u8).add(std::mem::size_of::()) as *mut f64; - mark_array_layout_unknown(arr); - let mut idx = 0usize; - // GC_STORE_AUDIT(BARRIERED): write-back is included in the rebuild below. - for i in 0..count { - *elements_ptr.add(idx) = temp.get(i); - idx += 1; - } - // GC_STORE_AUDIT(POINTER_FREE): undefined/hole suffix has no child pointer; - // covered by the rebuild below anyway. - for _ in 0..undef_count { - *elements_ptr.add(idx) = f64::from_bits(crate::value::TAG_UNDEFINED); - idx += 1; - } - // GC_STORE_AUDIT(POINTER_FREE): hole suffix has no child pointer. - for _ in 0..hole_count { - *elements_ptr.add(idx) = f64::from_bits(crate::value::TAG_HOLE); - idx += 1; - } - rebuild_array_layout(arr); - return arr; + debug_assert_eq!(count + undef_count + hole_count, length); + return publish_sorted_values(&arr_handle, &temp, count, undef_count, length, None); } let Some(c) = cmp else { @@ -805,35 +878,26 @@ unsafe fn sort_array_receiver( // #6076: sort a GC-rooted COPY of the elements and publish it back only // after the comparator sort SUCCEEDS. A throwing comparator therefore // leaves the receiver's elements intact (an in-place sort corrupts them). - // Both the copy AND the merge scratch are rooted arrays: the merge engine - // ping-pongs between two GC-visible buffers, so a moving minor during a - // comparator call rewrites whichever buffer holds the authoritative bits - // (the previous engine's bare `Vec` kept pre-move addresses and - // published them back into the receiver). - let cmp_handle = scope.root_raw_const_ptr(c.comparator); + // The copy stays rooted and unchanged throughout the comparator phase. + // The merge engine moves only indices; every comparison reads the source + // through its current handle after any prior callback moved its values. + let cmp_handle = cmp_handle.unwrap(); let temp = RootedArrayElems::new(&scope, js_array_alloc_with_length(length as u32)); { // Re-derive after the temp allocation above (which can GC). let arr = arr_handle.get_raw_mut_ptr::(); let recv_elems = (arr as *const u8).add(std::mem::size_of::()) as *const f64; - for i in 0..length { - temp.set(i, *recv_elems.add(i)); - } + let dest = (temp.arr() as *mut u8).add(std::mem::size_of::()) as *mut f64; + // GC_STORE_AUDIT(BARRIERED): initializing the private snapshot has no + // safepoint before the layout/remembered-edge rebuild immediately below. + std::ptr::copy_nonoverlapping(recv_elems, dest, length); } rebuild_array_layout(temp.arr()); - let scratch = RootedArrayElems::new(&scope, js_array_alloc_with_length(length as u32)); - sort_comparator_rooted(&temp, &scratch, length, &c, &cmp_handle); - - // The comparator sort completed without a throw — publish the sorted temp - // back into the receiver (no user code below; both sides re-derived). - let arr = arr_handle.get_raw_mut_ptr::(); - let recv_elems = (arr as *mut u8).add(std::mem::size_of::()) as *mut f64; - mark_array_layout_unknown(arr); - for i in 0..length { - // GC_STORE_AUDIT(BARRIERED): dense write-back is followed by the rebuild below. - *recv_elems.add(i) = temp.get(i); - } - rebuild_array_layout(arr); - - arr + // Consume the permutation directly while its workspace is still rooted. + // The source stays immutable; this avoids permuting/rebuilding it only + // to copy it into the receiver. Exotic write-back may call setters and + // move values, so it continues to read the source through current roots. + with_sorted_indices(&temp, length, &c, &cmp_handle, |order| { + publish_sorted_values(&arr_handle, &temp, length, 0, length, Some(order)) + }) } diff --git a/crates/perry-runtime/src/array/sort_indices.rs b/crates/perry-runtime/src/array/sort_indices.rs new file mode 100644 index 0000000000..755a97abb5 --- /dev/null +++ b/crates/perry-runtime/src/array/sort_indices.rs @@ -0,0 +1,289 @@ +//! Stable natural merge sort of an index permutation. Only integer indices +//! move while a comparator runs; the caller keeps the source values rooted. +//! Unlike slice::sort_by, inconsistent comparators cannot cause a panic or +//! lose elements. The caller also owns scratch so JS throws cannot leak Vecs. + +const MIN_RUN: usize = 32; + +#[derive(Clone, Copy, Default)] +struct Run { + start: usize, + len: usize, +} + +/// Sort an identity permutation with caller-provided, equally-sized scratch. +/// `le(a, b)` compares source values at indices a and b, treating equality as +/// true. Indices never escape the input range, even for inconsistent `le`. +pub(super) fn sort_indices( + order: &mut [u32], + scratch: &mut [u32], + mut le: impl FnMut(u32, u32) -> bool, +) { + let len = order.len(); + assert!(len <= u32::MAX as usize && scratch.len() >= len); + // The collapse invariant makes pending lengths grow at least as fast as + // Fibonacci numbers. 64 entries suffice for every u32-sized JS array. + let mut runs = [Run::default(); 64]; + let mut pending = 0; + let mut start = 0; + while start < len { + let mut end = start + 1; + if end < len { + let ascending = le(order[start], order[end]); + end += 1; + while end < len && le(order[end - 1], order[end]) == ascending { + end += 1; + } + if !ascending { + // Strictly descending only: reversing equal elements would + // break stability. NaN comparator results are equal upstream. + order[start..end].reverse(); + } + } + let run_end = end.max(start.saturating_add(MIN_RUN).min(len)); + // Extend short runs with binary insertion. An index can stay in a + // register across a callback; a copied heap value could not. + for i in end..run_end { + let key = order[i]; + let (mut lo, mut hi) = (start, i); + while lo < hi { + let mid = lo + (hi - lo) / 2; + if le(order[mid], key) { + lo = mid + 1; + } else { + hi = mid; + } + } + order.copy_within(lo..i, lo + 1); + order[lo] = key; + } + runs[pending] = Run { + start, + len: run_end - start, + }; + pending += 1; + start = run_end; + + // Restore BOTH three-run inequalities, including the one below the + // top triple. Checking only the top triple can overflow a run stack + // on adversarial run-length sequences. + while pending > 1 { + let mut i = pending - 2; + if (i > 0 && runs[i - 1].len <= runs[i].len + runs[i + 1].len) + || (i > 1 && runs[i - 2].len <= runs[i - 1].len + runs[i].len) + { + if runs[i - 1].len < runs[i + 1].len { + i -= 1; + } + } else if runs[i].len > runs[i + 1].len { + break; + } + merge_at(order, scratch, &mut runs, &mut pending, i, &mut le); + } + } + while pending > 1 { + let mut i = pending - 2; + if i > 0 && runs[i - 1].len < runs[i + 1].len { + i -= 1; + } + merge_at(order, scratch, &mut runs, &mut pending, i, &mut le); + } +} + +fn merge_at( + order: &mut [u32], + scratch: &mut [u32], + runs: &mut [Run], + pending: &mut usize, + i: usize, + le: &mut impl FnMut(u32, u32) -> bool, +) { + let start = runs[i].start; + let mid = start + runs[i].len; + let end = mid + runs[i + 1].len; + runs[i].len += runs[i + 1].len; + runs.copy_within(i + 2..*pending, i + 1); + *pending -= 1; + if le(order[mid - 1], order[mid]) { + return; + } + // Only the left run needs a snapshot. While it has unconsumed values, + // dest < right, so forward stores cannot overwrite the right run's next + // unread index. Once the left run is empty, the right tail is in place. + scratch[start..mid].copy_from_slice(&order[start..mid]); + let (mut left, mut right, mut dest) = (start, mid, start); + let (mut left_wins, mut right_wins) = (0, 0); + while left < mid && right < end { + // Left wins ties, preserving the order of equivalent source values. + if le(scratch[left], order[right]) { + order[dest] = scratch[left]; + left += 1; + left_wins += 1; + right_wins = 0; + } else { + order[dest] = order[right]; + right += 1; + right_wins += 1; + left_wins = 0; + } + dest += 1; + // When one run repeatedly wins, find its next block with exponential + // then binary search. This helps clustered and duplicate-heavy data + // without imposing a binary search on each random-data comparison. + if left_wins >= 7 && left < mid && right < end { + let take = gallop_prefix(&scratch[left..mid], |item| le(item, order[right])); + order[dest..dest + take].copy_from_slice(&scratch[left..left + take]); + left += take; + dest += take; + left_wins = 0; + } else if right_wins >= 7 && left < mid && right < end { + // Strictly less on the right: ties must stay behind the left run. + let take = gallop_prefix(&order[right..end], |item| !le(scratch[left], item)); + order.copy_within(right..right + take, dest); + right += take; + dest += take; + right_wins = 0; + } + } + order[dest..dest + mid - left].copy_from_slice(&scratch[left..mid]); +} + +fn gallop_prefix(values: &[u32], mut belongs: impl FnMut(u32) -> bool) -> usize { + let (mut lo, mut probe) = (0, 0usize); + while probe < values.len() && belongs(values[probe]) { + lo = probe + 1; + probe = probe.saturating_mul(2).saturating_add(1); + } + let mut hi = probe.min(values.len()); + while lo < hi { + let mid = lo + (hi - lo) / 2; + if belongs(values[mid]) { + lo = mid + 1; + } else { + hi = mid; + } + } + lo +} + +#[cfg(test)] +mod tests { + use super::sort_indices; + + fn check(values: &[i32]) -> usize { + let mut actual: Vec = (0..values.len() as u32).collect(); + let mut expected = actual.clone(); + expected.sort_by_key(|&i| values[i as usize]); + let mut calls = 0; + sort_indices(&mut actual, &mut vec![0; values.len()], |a, b| { + calls += 1; + values[a as usize] <= values[b as usize] + }); + assert_eq!(actual, expected, "stable order for length {}", values.len()); + calls + } + + #[test] + fn stable_across_distributions_and_run_boundaries() { + let mut state = 289u32; + for n in [0, 1, 2, 3, 7, 31, 32, 33, 63, 64, 65, 127, 1023, 4096] { + let random: Vec = (0..n) + .map(|_| { + state = state.wrapping_mul(1664525).wrapping_add(1013904223); + (state >> 16) as i32 + }) + .collect(); + check(&random); + check(&(0..n).map(|i| i as i32).collect::>()); + check(&(0..n).map(|i| -(i as i32)).collect::>()); + check(&vec![1; n]); + check(&random.iter().map(|x| x % 7).collect::>()); + check(&(0..n).map(|i| -((i / 3) as i32)).collect::>()); + check(&(0..n).map(|i| (i % 73) as i32).collect::>()); + check(&(0..n).map(|i| i.min(n - i) as i32).collect::>()); + } + } + + #[test] + fn exhaustive_duplicate_permutations_are_stable() { + for n in 0..=8 { + for mut encoded in 0..3usize.pow(n) { + let mut values = vec![0; n as usize]; + for value in &mut values { + *value = (encoded % 3) as i32; + encoded /= 3; + } + check(&values); + } + } + } + + #[test] + fn linear_comparisons_for_natural_runs() { + for n in [33, 1000, 100_000] { + assert_eq!(check(&(0..n).collect::>()), (n - 1) as usize); + assert_eq!(check(&(0..n).rev().collect::>()), (n - 1) as usize); + assert_eq!(check(&vec![0; n as usize]), (n - 1) as usize); + } + } + + #[test] + fn inconsistent_comparators_preserve_every_index_and_terminate() { + for n in [3, 33, 1024, 10000] { + for mode in 0..4 { + let mut order: Vec = (0..n).collect(); + let mut calls = 0usize; + sort_indices(&mut order, &mut vec![0; n as usize], |a, b| { + calls += 1; + assert!(calls < n as usize * 100); + match mode { + 0 => true, + 1 => false, + 2 => calls % 2 == 0, + _ => (a + 1) % 3 == b % 3, + } + }); + order.sort_unstable(); + assert_eq!(order, (0..n).collect::>()); + } + } + } + + #[test] + fn uneven_runs_keep_merges_balanced() { + // Alternating lengths with nested near-Fibonacci groups stress both + // the top and the lower run-stack collapse inequalities. + let lengths = [ + 161, 97, 63, 33, 31, 65, 129, 4097, 2049, 1025, 513, 257, 129, 65, 33, + ]; + let mut values = Vec::new(); + for round in 0..20 { + for &len in &lengths { + let base = -((values.len() + round * 13) as i32); + values.extend((0..len).map(|i| base + i as i32)); + } + } + assert!(check(&values) < values.len() * 30); + } + + #[test] + fn large_disjoint_runs_skip_blocks_without_losing_stability() { + let values: Vec = (50_000..100_000).chain(0..50_000).collect(); + assert!(check(&values) < values.len() + 100); + let duplicates: Vec = values.iter().map(|x| x / 100).collect(); + assert!(check(&duplicates) < duplicates.len() + 100); + } + + #[test] + fn gallop_boundaries() { + for n in 0..128 { + let values: Vec = (0..n).collect(); + for boundary in 0..=n { + assert_eq!( + super::gallop_prefix(&values, |x| x < boundary), + boundary as usize + ); + } + } + } +} diff --git a/crates/perry-runtime/src/builtins/arithmetic.rs b/crates/perry-runtime/src/builtins/arithmetic.rs index c405fb2325..f98306d20c 100644 --- a/crates/perry-runtime/src/builtins/arithmetic.rs +++ b/crates/perry-runtime/src/builtins/arithmetic.rs @@ -459,6 +459,9 @@ fn rel_numeric_operand(v: f64) -> Option { /// both statically numeric. Returns a NaN-boxed boolean (`f64`). #[no_mangle] pub extern "C" fn js_rel_lt(x: f64, y: f64) -> f64 { + if let Some(order) = crate::string::compare_primitive_strings(x, y) { + return rel_bool_f64(order < 0); + } if let (Some(a), Some(b)) = (rel_numeric_operand(x), rel_numeric_operand(y)) { return rel_bool_f64(a < b); } @@ -468,6 +471,9 @@ pub extern "C" fn js_rel_lt(x: f64, y: f64) -> f64 { /// `x > y` ⇔ `IsLessThan(y, x, false)` is true (right operand `ToPrimitive`'d first). #[no_mangle] pub extern "C" fn js_rel_gt(x: f64, y: f64) -> f64 { + if let Some(order) = crate::string::compare_primitive_strings(x, y) { + return rel_bool_f64(order > 0); + } if let (Some(a), Some(b)) = (rel_numeric_operand(x), rel_numeric_operand(y)) { return rel_bool_f64(a > b); } @@ -477,6 +483,9 @@ pub extern "C" fn js_rel_gt(x: f64, y: f64) -> f64 { /// `x <= y` ⇔ `IsLessThan(y, x, false)` is `false` (not `true`, not `undefined`). #[no_mangle] pub extern "C" fn js_rel_le(x: f64, y: f64) -> f64 { + if let Some(order) = crate::string::compare_primitive_strings(x, y) { + return rel_bool_f64(order <= 0); + } if let (Some(a), Some(b)) = (rel_numeric_operand(x), rel_numeric_operand(y)) { return rel_bool_f64(a <= b); } @@ -486,6 +495,9 @@ pub extern "C" fn js_rel_le(x: f64, y: f64) -> f64 { /// `x >= y` ⇔ `IsLessThan(x, y, true)` is `false` (not `true`, not `undefined`). #[no_mangle] pub extern "C" fn js_rel_ge(x: f64, y: f64) -> f64 { + if let Some(order) = crate::string::compare_primitive_strings(x, y) { + return rel_bool_f64(order >= 0); + } if let (Some(a), Some(b)) = (rel_numeric_operand(x), rel_numeric_operand(y)) { return rel_bool_f64(a >= b); } @@ -865,6 +877,43 @@ mod rel_numeric_fastpath_tests { v.to_bits() == TAG_TRUE_BITS } + #[test] + fn primitive_string_relations_match_utf16_for_heap_and_inline_values() { + let scope = crate::gc::RuntimeHandleScope::new(); + let words = [ + "", + "a", + "ab", + "abcde", + "abcdefghij", + "é", + "\u{10000}", + "\u{e000}", + ]; + let mut values = Vec::new(); + for word in words { + let heap = crate::string::js_string_from_bytes(word.as_ptr(), word.len() as u32); + values.push(( + word, + scope.root_nanbox_f64(crate::value::js_nanbox_string(heap as i64)), + )); + if word.len() <= crate::value::SHORT_STRING_MAX_LEN { + let short = crate::value::JSValue::try_short_string(word.as_bytes()).unwrap(); + values.push((word, scope.root_nanbox_f64(f64::from_bits(short.bits())))); + } + } + for (a, av) in &values { + for (b, bv) in &values { + let order = a.encode_utf16().cmp(b.encode_utf16()); + let (x, y) = (av.get_nanbox_f64(), bv.get_nanbox_f64()); + assert_eq!(is_true(js_rel_lt(x, y)), order.is_lt(), "{a:?} < {b:?}"); + assert_eq!(is_true(js_rel_gt(x, y)), order.is_gt(), "{a:?} > {b:?}"); + assert_eq!(is_true(js_rel_le(x, y)), !order.is_gt(), "{a:?} <= {b:?}"); + assert_eq!(is_true(js_rel_ge(x, y)), !order.is_lt(), "{a:?} >= {b:?}"); + } + } + } + #[test] fn integer_typeof_classifier_covers_the_primitive_tag_families() { let cases = [ diff --git a/crates/perry-runtime/src/closure/dispatch/direct.rs b/crates/perry-runtime/src/closure/dispatch/direct.rs index d3e42cc6ab..c9e21a1196 100644 --- a/crates/perry-runtime/src/closure/dispatch/direct.rs +++ b/crates/perry-runtime/src/closure/dispatch/direct.rs @@ -23,10 +23,10 @@ //! Each input is invariant for a FIXED closure: //! //! * `closure->func_ptr` is written once by `js_closure_alloc` and never -//! mutated, and a `ClosureHeader` is non-movable, so `get_valid_func_ptr` -//! answers the same address every time. (A moving collection cannot change -//! it either — `direct` is a static CODE address; that is the same argument -//! `ComparatorCall::compare_at` documents.) +//! mutated. Collection can move the ClosureHeader, but its function field +//! still holds the same static code address. Callers must re-read the +//! closure argument from a current root before calling that code, as +//! `ComparatorCall::less_equal_at` documents. //! * `lookup_closure_rest` / `lookup_closure_arity` are keyed by that //! func_ptr, and both registries are insert-only per key — the registration //! happens at closure creation, before the closure can be passed anywhere. @@ -157,13 +157,13 @@ macro_rules! define_direct_call_site { ) => { $(#[$meta])* #[derive(Clone, Copy)] - pub struct $site(Option f64>); + pub struct $site(extern "C" fn(*const ClosureHeader, $(define_direct_call_site!(@f64 $arg)),+) -> f64); impl $site { /// Resolve `closure` once, before the loop. #[inline] pub fn resolve(closure: *const ClosureHeader) -> Self { - $site(resolve_direct_func_ptr(closure, $arity).map(|func_ptr| unsafe { + $site(resolve_direct_func_ptr(closure, $arity).map_or($slow, |func_ptr| unsafe { std::mem::transmute::< *const u8, extern "C" fn(*const ClosureHeader, $(define_direct_call_site!(@f64 $arg)),+) -> f64, @@ -176,10 +176,7 @@ macro_rules! define_direct_call_site { /// did not resolve. #[inline] pub fn call(&self, closure: *const ClosureHeader, $($arg: f64),+) -> f64 { - match self.0 { - Some(func) => func(closure, $($arg),+), - None => $slow(closure, $($arg),+), - } + (self.0)(closure, $($arg),+) } /// Whether the direct target was resolved. Test-only: a "fast @@ -187,7 +184,10 @@ macro_rules! define_direct_call_site { #[cfg(test)] #[allow(dead_code)] pub(crate) fn is_direct(&self) -> bool { - self.0.is_some() + !std::ptr::fn_addr_eq( + self.0, + $slow as extern "C" fn(*const ClosureHeader, $(define_direct_call_site!(@f64 $arg)),+) -> f64, + ) } } }; diff --git a/crates/perry-runtime/src/gc/roots.rs b/crates/perry-runtime/src/gc/roots.rs index 4b6483f14d..661431c69b 100644 --- a/crates/perry-runtime/src/gc/roots.rs +++ b/crates/perry-runtime/src/gc/roots.rs @@ -7,6 +7,7 @@ mod scan_mode; mod scanner_shims; mod shadow_stack; mod stack_maps; +mod stack_roots; pub(crate) use stack_maps::census_rows::stack_map_index_census; mod temp_roots; pub(super) use stack_maps::ensure_built as ensure_stack_maps_built; @@ -36,6 +37,7 @@ pub use scanner_shims::{ small_int_cache_mutable_root_scanner, small_int_cache_root_scanner, timer_mutable_root_scanner, timer_root_scanner, transition_cache_mutable_root_scanner, transition_cache_root_scanner, }; +pub(crate) use stack_roots::with_stack_roots; // The conservative-scan mode lives in `roots/scan_mode.rs` (split out in #7148 // when this file crossed the 2,000-line gate) but every consumer names it // `gc::roots::…` or reaches it through `mod.rs`'s `pub use roots::*`, so the diff --git a/crates/perry-runtime/src/gc/roots/stack_roots.rs b/crates/perry-runtime/src/gc/roots/stack_roots.rs new file mode 100644 index 0000000000..c0fc31ee13 --- /dev/null +++ b/crates/perry-runtime/src/gc/roots/stack_roots.rs @@ -0,0 +1,57 @@ +//! Fixed-size roots for native callback loops. The existing shadow scanner +//! marks and rewrites their bound stack cells, so reading a current value does +//! not require a TLS lookup or indexing a growable handle buffer. + +use super::shadow_stack::{js_shadow_frame_pop, js_shadow_frame_push, js_shadow_slot_bind}; +use std::cell::UnsafeCell; + +/// Run with fixed stack cells containing tagged JSValues or bare object-start +/// addresses (the shadow-stack root-word contract). The cells stay at the same +/// address until `f` returns; only the collector can change their contents. +/// +/// Normal return and Rust unwind pop the frame before the cells die. A JS +/// throw restores the shadow savepoint before longjmp, removing the bindings +/// into the abandoned native frame even though Rust destructors do not run. +pub(crate) fn with_stack_roots( + values: [u64; N], + f: impl FnOnce(&StackRoots) -> R, +) -> R { + let roots = StackRoots { + cells: UnsafeCell::new(values), + }; + let frame = Frame(js_shadow_frame_push( + u32::try_from(N).expect("too many stack roots"), + )); + for index in 0..N { + // UnsafeCell permits collector writes through the registered pointer + // while `f` holds a shared reference. No Rust reference to a cell's + // contents escapes, and the callback cannot move `roots` itself. + js_shadow_slot_bind(index as u32, unsafe { + roots.cells.get().cast::().add(index) + }); + } + let result = f(&roots); + drop(frame); + result +} + +pub(crate) struct StackRoots { + cells: UnsafeCell<[u64; N]>, +} + +impl StackRoots { + #[inline(always)] + pub(crate) fn get(&self, index: usize) -> u64 { + assert!(index < N); + // Copy only the current word; a later callback can rewrite this cell. + unsafe { self.cells.get().cast::().add(index).read() } + } +} + +struct Frame(u64); + +impl Drop for Frame { + fn drop(&mut self) { + js_shadow_frame_pop(self.0); + } +} diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots.rs b/crates/perry-runtime/src/gc/tests/runtime_roots.rs index ba9900fec3..8e3737c1cb 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots.rs @@ -20,6 +20,7 @@ mod prototype_addr_cache; mod regexp_last_index; mod segment_record_keys; mod side_table_scanners; +mod sort_collection; mod string_normalize_form; mod string_slice; mod symbol_description; diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/callback_scanners.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/callback_scanners.rs index 2e6bace973..04802ae7e3 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/callback_scanners.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/callback_scanners.rs @@ -1591,6 +1591,10 @@ fn string_value_content(value: f64) -> String { crate::string::string_as_str((bits & POINTER_MASK) as *const crate::StringHeader).to_string() } +thread_local! { + static SORT_COPIED_OBJECTS: Cell = const { Cell::new(0) }; +} + extern "C" fn test_sort_comparator_force_minor_gc( _closure: *const crate::closure::ClosureHeader, a: f64, @@ -1599,7 +1603,14 @@ extern "C" fn test_sort_comparator_force_minor_gc( let scope = RuntimeHandleScope::new(); let a_handle = scope.root_nanbox_f64(a); let b_handle = scope.root_nanbox_f64(b); - let _ = crate::gc::gc_collect_minor(); + let trace = collect_minor_trace(GcTriggerKind::Direct); + SORT_COPIED_OBJECTS.with(|count| { + count.set( + count.get() + + trace.copying_nursery.copied_objects + + trace.copying_nursery.promoted_objects, + ); + }); let a_str = string_value_content(a_handle.get_nanbox_f64()); let b_str = string_value_content(b_handle.get_nanbox_f64()); match a_str.cmp(&b_str) { @@ -1610,17 +1621,18 @@ extern "C" fn test_sort_comparator_force_minor_gc( } /// `Array.prototype.sort(comparator)` where every comparator call forces a -/// copied minor GC: the receiver, the #6076 temp copy, the merge scratch, and -/// the comparator closure itself must all be re-derived from rooted handles. -/// 40 elements exercises the run + bottom-up-merge engine (threshold 32); the -/// 8-element pass covers the pure insertion-sort path. +/// copied minor GC: the receiver, the private source snapshot, and the +/// comparator closure itself must all be re-derived from rooted handles. +/// Both monotone and shuffled inputs exercise run detection, binary insertion, +/// merging, and the stack / GC-owned index workspace boundary. #[test] fn test_array_sort_comparator_rooted_buffers_survive_copied_minor_gc() { let _guard = CopyingNurseryTestGuard::new(0); let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); register_runtime_handle_root_scanner_for_tests(); - for count in [8usize, 40usize] { + for (count, shuffled) in [(8usize, false), (40, true), (128, false), (128, true)] { + SORT_COPIED_OBJECTS.with(|count| count.set(0)); let scope = RuntimeHandleScope::new(); let comparator = crate::closure::js_closure_alloc(test_sort_comparator_force_minor_gc as *const u8, 0); @@ -1628,9 +1640,12 @@ fn test_array_sort_comparator_rooted_buffers_survive_copied_minor_gc() { let arr = crate::array::js_array_alloc(count as u32); let arr_handle = scope.root_raw_mut_ptr(arr); for i in 0..count { - // Descending heap strings ("s39", "s38", …) so the sort has real - // work and stale pre-move addresses are observable as garbage. - let text = format!("s{:02}", count - 1 - i); + let key = if shuffled { + (i * 17) % count + } else { + count - 1 - i + }; + let text = format!("s{key:03}"); let sp = crate::string::js_string_from_bytes(text.as_ptr(), text.len() as u32); let sp_handle = scope.root_string_ptr(sp); let arr = crate::array::js_array_push( @@ -1651,6 +1666,12 @@ fn test_array_sort_comparator_rooted_buffers_survive_copied_minor_gc() { gc_collection_count() > before, "comparator should force copied-minor GCs during the sort" ); + SORT_COPIED_OBJECTS.with(|copied| { + assert!( + copied.get() >= count, + "the sort must actually relocate heap values" + ); + }); unsafe { assert_eq!((*sorted).length as usize, count); let elems = (sorted as *const u8).add(std::mem::size_of::()) @@ -1659,7 +1680,7 @@ fn test_array_sort_comparator_rooted_buffers_survive_copied_minor_gc() { let got = string_value_content(*elems.add(i)); assert_eq!( got, - format!("s{i:02}"), + format!("s{i:03}"), "element {i} of the {count}-element sort should hold the \ post-move string, not a stale pre-move address" ); diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/sort_collection.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/sort_collection.rs new file mode 100644 index 0000000000..9091b6deca --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/sort_collection.rs @@ -0,0 +1,125 @@ +use super::*; +use crate::closure::ClosureHeader; + +extern "C" fn captured_direction(closure: *const ClosureHeader, a: f64, b: f64) -> f64 { + // Model generated capture loads: no forwarding lookup can repair a stale + // closure argument. The getter changes only the relocated capture. + let direction = unsafe { + *((closure as *const u8).add(std::mem::size_of::()) as *const f64) + }; + direction * (a - b) +} + +extern "C" fn collecting_getter(closure: *const ClosureHeader) -> f64 { + let scope = RuntimeHandleScope::new(); + let getter = scope.root_raw_const_ptr(closure); + gc_collect_minor(); + getter.with_const_ptr(|current| { + let comparator = crate::closure::js_closure_get_capture_f64(current, 0); + crate::closure::js_closure_set_capture_f64( + (comparator.to_bits() & crate::value::POINTER_MASK) as *mut ClosureHeader, + 0, + 1.0, + ); + }); + 3.0 +} + +extern "C" fn accept_sorted_value(_closure: *const ClosureHeader, _value: f64) -> f64 { + f64::from_bits(crate::value::TAG_UNDEFINED) +} + +#[test] +fn sort_collection_getters_relocate_comparator_and_receiver() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _evacuation = ForcedEvacuationTestGuard::on(); + let _protection = + crate::arena::ProtectionModeGuard::set(crate::arena::FromSpaceProtection::PoisonOnly); + register_runtime_handle_root_scanner_for_tests(); + + for real_array in [true, false] { + let scope = RuntimeHandleScope::new(); + let comparator = scope.root_raw_mut_ptr(crate::closure::js_closure_alloc( + captured_direction as *const u8, + 1, + )); + comparator.with_mut_ptr(|ptr| crate::closure::js_closure_set_capture_f64(ptr, 0, -1.0)); + let recv = if real_array { + crate::array::js_array_alloc_with_length(3) as *mut u8 + } else { + crate::object::js_object_alloc(0, 0) as *mut u8 + }; + let receiver = scope.root_nanbox_u64(ptr_bits(recv as usize)); + for (index, value) in [3.0, 1.0, 2.0].into_iter().enumerate() { + crate::object::js_object_set_index_polymorphic( + receiver.get_nanbox_u64() as i64, + index as f64, + value, + ); + } + let getter = scope.root_raw_mut_ptr(crate::closure::js_closure_alloc( + collecting_getter as *const u8, + 1, + )); + getter.with_mut_ptr(|getter| { + comparator.with_const_ptr(|cmp: *const ClosureHeader| { + crate::closure::js_closure_set_capture_f64( + getter, + 0, + f64::from_bits(ptr_bits(cmp as usize)), + ); + }); + }); + let setter = scope.root_raw_mut_ptr(crate::closure::js_closure_alloc( + accept_sorted_value as *const u8, + 0, + )); + let descriptor = scope.root_raw_mut_ptr(crate::object::js_object_alloc(0, 0)); + for (name, function) in [(b"get", &getter), (b"set", &setter)] { + let key = crate::js_string_from_bytes(name.as_ptr(), name.len() as u32); + descriptor.with_mut_ptr(|desc| { + function.with_const_ptr(|func: *const ClosureHeader| { + crate::object::js_object_set_field_by_name( + desc, + key, + f64::from_bits(ptr_bits(func as usize)), + ); + }); + }); + } + let name: &[u8] = if real_array { b"0" } else { b"length" }; + let key = crate::js_string_from_bytes(name.as_ptr(), name.len() as u32); + descriptor.with_const_ptr(|desc: *const u8| { + crate::object::js_object_define_property( + receiver.get_nanbox_f64(), + f64::from_bits(string_bits(key as usize)), + f64::from_bits(ptr_bits(desc as usize)), + ); + }); + let before_cmp = comparator.with_const_ptr(|ptr: *const ClosureHeader| ptr as usize); + let before_recv = receiver.get_nanbox_u64(); + let returned = comparator.with_const_ptr(|cmp| { + crate::array::js_array_sort_with_comparator( + (receiver.get_nanbox_u64() & crate::value::POINTER_MASK) as *mut _, + cmp, + ) + }); + assert_ne!( + comparator.with_const_ptr(|p: *const ClosureHeader| p as usize), + before_cmp + ); + assert_ne!(receiver.get_nanbox_u64(), before_recv); + assert_eq!(ptr_bits(returned as usize), receiver.get_nanbox_u64()); + for (index, expected) in [(1, 2.0), (2, 3.0)] { + let actual = crate::object::js_object_get_index_polymorphic( + receiver.get_nanbox_u64() as i64, + index as f64, + ); + assert_eq!( + actual, expected, + "receiver kind: real_array={real_array}, index={index}" + ); + } + } +} diff --git a/crates/perry-runtime/src/gc/tests/shadow_stack_ops.rs b/crates/perry-runtime/src/gc/tests/shadow_stack_ops.rs index bd8e543684..434ec8b901 100644 --- a/crates/perry-runtime/src/gc/tests/shadow_stack_ops.rs +++ b/crates/perry-runtime/src/gc/tests/shadow_stack_ops.rs @@ -239,6 +239,32 @@ fn bound_slot_survives_and_is_rewritten_by_a_copying_minor() { assert_ne!(moved, child, "test did not actually evacuate the object"); } +#[test] +fn native_stack_roots_survive_nested_frame_growth_and_real_evacuation() { + let _guard = CopyingNurseryTestGuard::new(0); + let tagged_child = young_leaf(); + let bare_child = young_leaf(); + let before = shadow_stack_depth(); + with_stack_roots([ptr_bits(tagged_child), bare_child as u64], |roots| { + // Grow the shadow buffer while the native cells remain bound. + let old_capacity = SHADOW.with(|cell| unsafe { (*cell.get()).cap }); + let nested = js_shadow_frame_push(old_capacity as u32 + 1); + assert!(SHADOW.with(|cell| unsafe { (*cell.get()).cap }) > old_capacity); + let _ = gc_collect_minor(); + for (index, old) in [(0, tagged_child), (1, bare_child)] { + let moved = (roots.get(index) & POINTER_MASK) as usize; + assert_ne!(moved, old, "the test must actually evacuate each object"); + assert!( + crate::arena::pointer_in_nursery(moved) || crate::arena::pointer_in_old_gen(moved) + ); + } + let current = roots.get(0); + js_shadow_frame_pop(nested); + assert_eq!(roots.get(0), current); + }); + assert_eq!(shadow_stack_depth(), before); +} + /// The same property for an unbound slot: the mirror word itself is the root /// slot, so it must be rewritten in place. #[test] diff --git a/crates/perry-runtime/src/object/field_get_set.rs b/crates/perry-runtime/src/object/field_get_set.rs index 1af9e016ac..318bb493d9 100644 --- a/crates/perry-runtime/src/object/field_get_set.rs +++ b/crates/perry-runtime/src/object/field_get_set.rs @@ -305,9 +305,9 @@ pub(crate) use ic_miss::{ }; pub use ic_miss::{ js_class_field_add, js_object_get_field_by_name_f64, js_object_get_field_by_property_id_f64, - js_object_get_field_ic, js_object_get_field_ic_miss, js_object_set_field_by_property_id, - js_private_brand_add, js_private_brand_check, js_private_field_add, js_private_guard, PicCache, - PicCacheSlot, PIC_CACHE_WORDS, + js_object_get_field_ic, js_object_get_field_ic_miss, js_object_get_field_ic_miss_packed, + js_object_set_field_by_property_id, js_private_brand_add, js_private_brand_check, + js_private_field_add, js_private_guard, PicCache, PicCacheSlot, PIC_CACHE_WORDS, }; pub(crate) use ic_slot::pic_slot_census; pub use ic_slot::{pic_arena_bytes, pic_slot_peek, pic_slot_resolve, pic_slots_resolved}; diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index a288bae136..41a847b423 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -297,12 +297,9 @@ const PIC_LATCH_RETRY: i64 = 2048; /// Prime the MRU entry, cascading the shape it evicts into the ways. /// -/// Word 0 keeps exactly its pre-#7753 meaning — last shape seen, always -/// overwritten — so a genuinely monomorphic site behaves identically. What -/// changes is that the *evicted* shape is no longer thrown away: it moves into -/// a way, and the emitted poly block (reached only after word 0 misses) -/// resolves it inline instead of calling back into this handler. A site that -/// alternates between k ≤ `PIC_WAYS + 1` shapes therefore stops thrashing. +/// Word 0 holds the last cacheable shape. An evicted shape moves into a way, +/// which generated code consults after the MRU misses. Sites alternating +/// between k ≤ `PIC_WAYS + 1` shapes therefore stop thrashing. /// /// Every token is derived from an authoritative, never-reused ShapeId. A shape /// transition therefore makes an old way go cold without requiring an address @@ -312,6 +309,11 @@ const PIC_LATCH_RETRY: i64 = 2048; /// `cache` must point at a live `[i64; PIC_CACHE_WORDS]` (the codegen-emitted /// per-site global, or a stack array of that type). pub(crate) unsafe fn pic_prime_get(cache: *mut PicCache, token: i64, slot: i64) { + // A token hit must prove a nonzero ShapeId, including in polymorphic ways. + // Keyless/unstamped receivers require the full prototype lookup. + if token == crate::object::shapes::PIC_ID_TOKEN_BIT as i64 { + return; + } let c = &mut *cache; let prev_tok = c[0]; let prev_slot = c[1]; @@ -531,11 +533,15 @@ fn ic_diag_note( crate::hot_diag::ic_note(site, bytes, reason); } -#[no_mangle] -pub extern "C" fn js_object_get_field_ic_miss( +#[path = "ic_miss/packed_get.rs"] +mod packed_get; +pub use packed_get::{js_object_get_field_ic_miss, js_object_get_field_ic_miss_packed}; + +fn get_field_ic_miss_impl( obj: *const ObjectHeader, key: *const crate::StringHeader, cache_slot: *mut PicCacheSlot, + packed: *const std::sync::atomic::AtomicU64, ) -> f64 { use crate::hot_diag::IcMissReason as R; if crate::hot_diag::receiver_repr_on() { @@ -915,10 +921,11 @@ pub extern "C" fn js_object_get_field_ic_miss( // overflow-primed entry. let cache = pic_slot_resolve(cache_slot); (*cache)[2] = 0; - pic_prime_get( + packed_get::prime_get( cache, token, (i as u32 | crate::proxy::IC_SLOT_OVERFLOW_BIT) as i64, + packed, ); if diag { ic_diag_note(cache_slot, key, R::OwnOverflowPrimed); @@ -970,7 +977,7 @@ pub extern "C" fn js_object_get_field_ic_miss( } let cache = pic_slot_resolve(cache_slot); (*cache)[2] = named_prefix_token; - pic_prime_get(cache, token, i as i64); + packed_get::prime_get(cache, token, i as i64, packed); if diag { ic_diag_note(cache_slot, key, R::OwnInlinePrimed); } diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss/c3c_pic_tests.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss/c3c_pic_tests.rs index 605e09471d..c3ef8e7825 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss/c3c_pic_tests.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss/c3c_pic_tests.rs @@ -302,3 +302,17 @@ fn a_fresh_class_instance_computes_the_token_the_miss_handler_primed() { ); } } + +#[test] +fn unstamped_receiver_cannot_prime_or_poison_get_cache() { + let empty = crate::object::shapes::PIC_ID_TOKEN_BIT as i64; + let mut cache = [0i64; super::PIC_CACHE_WORDS]; + unsafe { super::pic_prime_get(&mut cache, empty, 0) }; + assert_eq!(cache, [0; super::PIC_CACHE_WORDS]); + for id in 1..=4 { + unsafe { super::pic_prime_get(&mut cache, empty | id, id) }; + let before = cache; + unsafe { super::pic_prime_get(&mut cache, empty, 123) }; + assert_eq!(cache, before, "unstamped reads must preserve MRU and ways"); + } +} diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss/packed_get.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss/packed_get.rs new file mode 100644 index 0000000000..aa3e424165 --- /dev/null +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss/packed_get.rs @@ -0,0 +1,84 @@ +//! Compact MRU for generated generic property reads. The extra eight-byte +//! site word contains only a ShapeId and slot, never a GC address. Publishing +//! the pair atomically prevents readers from mixing two priming operations. +use super::{ObjectHeader, PicCache, PicCacheSlot}; +use std::sync::atomic::{AtomicU64, Ordering}; + +#[no_mangle] +pub extern "C" fn js_object_get_field_ic_miss( + obj: *const ObjectHeader, + key: *const crate::StringHeader, + cache_slot: *mut PicCacheSlot, +) -> f64 { + super::get_field_ic_miss_impl(obj, key, cache_slot, std::ptr::null()) +} + +#[no_mangle] +pub extern "C" fn js_object_get_field_ic_miss_packed( + obj: *const ObjectHeader, + key: *const crate::StringHeader, + cache_slot: *mut PicCacheSlot, + packed: *const AtomicU64, +) -> f64 { + super::get_field_ic_miss_impl(obj, key, cache_slot, packed) +} + +/// Called at the same two authoritative own-slot proofs as the full cache. +/// The pair comes from that lookup, never from separately loaded cache words. +/// `packed` is null or an aligned, live AtomicU64 site word. +pub(super) unsafe fn prime_get( + cache: *mut PicCache, + token: i64, + slot: i64, + packed: *const AtomicU64, +) { + super::pic_prime_get(cache, token, slot); + if packed.is_null() { + return; + } + let stamp = token as u32; + if !(crate::object::shapes::SHAPE_ID_BASE..crate::object::shapes::SHAPE_ID_END).contains(&stamp) + || token as u64 != (stamp as u64 | crate::object::shapes::PIC_ID_TOKEN_BIT) + || !(0..=0x7fff_ffff).contains(&slot) + { + return; + } + // Low 32 bits: exact nonzero ShapeId. High 32: slot, including the + // overflow flag. Generated code rejects the zero-initialized word. + // Relaxed suffices: this publishes a numeric layout fact, not an object. + (*packed).store((slot as u64) << 32 | stamp as u64, Ordering::Relaxed); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn packed_pair_preserves_identity_overflow_and_empty_site() { + let packed = AtomicU64::new(0); + let mut cache = [0; super::super::PIC_CACHE_WORDS]; + let bit = crate::object::shapes::PIC_ID_TOKEN_BIT; + unsafe { + prime_get(&mut cache, bit as i64, 0, &packed); + } + assert_eq!(packed.load(Ordering::Relaxed), 0); + for stamp in [ + crate::object::shapes::SHAPE_ID_BASE, + crate::object::shapes::SHAPE_ID_END - 1, + ] { + for slot in [0, 1, 1 << 30, 0x7fff_ffff] { + unsafe { + prime_get(&mut cache, (bit | stamp as u64) as i64, slot, &packed); + } + let word = packed.load(Ordering::Relaxed); + assert_eq!(word & 0xffff_ffff, stamp as u64); + assert_eq!(word >> 32, slot as u64); + let before = word; + unsafe { + prime_get(&mut cache, bit as i64, 0, &packed); + } + assert_eq!(packed.load(Ordering::Relaxed), before); + } + } + } +} diff --git a/crates/perry-runtime/src/string/compare.rs b/crates/perry-runtime/src/string/compare.rs index 164413be84..852670652c 100644 --- a/crates/perry-runtime/src/string/compare.rs +++ b/crates/perry-runtime/src/string/compare.rs @@ -41,7 +41,66 @@ use super::*; /// Mixed operands (one ASCII, one not) deliberately fall through unchanged /// rather than reasoning about lead-byte ranges: the general path already /// handles them, and this stays a decision about *both* operands. +#[inline] pub(crate) fn utf16_cmp_bytes(a: &[u8], b: &[u8]) -> std::cmp::Ordering { + // For short common prefixes, find the first unequal byte a word at a + // time. An ASCII difference has the same order in UTF-8 and UTF-16 even + // if the equal prefix contains Unicode; no scan of the suffix is needed. + // A non-ASCII difference retains the full UTF-16/invalid-byte behavior. + let common = a.len().min(b.len()); + if common <= 32 { + let mut offset = 0; + while offset + 8 <= common { + let left = u64::from_le_bytes(a[offset..offset + 8].try_into().unwrap()); + let right = u64::from_le_bytes(b[offset..offset + 8].try_into().unwrap()); + let unequal = left ^ right; + if unequal != 0 { + return compare_unequal_words(left, right, a, b); + } + offset += 8; + } + if offset < common && common >= 8 { + // Load the final word inside the payload, overlapping the equal + // prefix instead of comparing a short tail one byte at a time. + let tail = common - 8; + let left = u64::from_le_bytes(a[tail..common].try_into().unwrap()); + let right = u64::from_le_bytes(b[tail..common].try_into().unwrap()); + if left != right { + return compare_unequal_words(left, right, a, b); + } + return a.len().cmp(&b.len()); + } + while offset < common { + let (x, y) = (a[offset], b[offset]); + if x != y { + return if (x | y) < 0x80 { + x.cmp(&y) + } else { + utf16_cmp_bytes_full(a, b) + }; + } + offset += 1; + } + return a.len().cmp(&b.len()); + } + utf16_cmp_bytes_full(a, b) +} + +#[inline] +fn compare_unequal_words(left: u64, right: u64, a: &[u8], b: &[u8]) -> std::cmp::Ordering { + if (left | right) & 0x8080_8080_8080_8080 == 0 { + return left.swap_bytes().cmp(&right.swap_bytes()); + } + let shift = (left ^ right).trailing_zeros() & !7; + let (x, y) = ((left >> shift) as u8, (right >> shift) as u8); + if (x | y) < 0x80 { + x.cmp(&y) + } else { + utf16_cmp_bytes_full(a, b) + } +} + +fn utf16_cmp_bytes_full(a: &[u8], b: &[u8]) -> std::cmp::Ordering { if a.is_ascii() && b.is_ascii() { return a.cmp(b); } @@ -133,6 +192,9 @@ pub extern "C" fn js_string_equals(a: *const StringHeader, b: *const StringHeade /// Returns -1 / 0 / 1. #[no_mangle] pub extern "C" fn js_string_compare_value(a: f64, b: f64) -> i32 { + if let Some(order) = compare_primitive_strings(a, b) { + return order; + } // Phase 1 — ALLOCATING coercions only. `js_number_to_string` allocates, // and an allocation can run a GC cycle that MOVES the other operand's // heap string (evacuation); the decimal bytes are therefore copied into @@ -189,6 +251,74 @@ pub extern "C" fn js_string_compare_value(a: f64, b: f64) -> i32 { } } +/// Compare primitive strings without entering the allocating number-to-string +/// adapter. Both heap and inline strings use the same UTF-16 ordering. +#[inline(always)] +pub(crate) fn compare_primitive_strings(a: f64, b: f64) -> Option { + let a_value = crate::JSValue::from_bits(a.to_bits()); + let b_value = crate::JSValue::from_bits(b.to_bits()); + if a_value.is_string() && b_value.is_string() { + if a.to_bits() == b.to_bits() { + return Some(0); + } + // Heap strings need no inline-string scratch storage. Keep that + // representation adapter out of the hot frame entirely. No allocation + // or callback can invalidate either borrowed byte view here. + unsafe { + let a_ptr = a_value.as_string_ptr(); + let b_ptr = b_value.as_string_ptr(); + let a_bytes = if a_ptr.is_null() { + &[] + } else { + std::slice::from_raw_parts(string_data(a_ptr), (*a_ptr).byte_len as usize) + }; + let b_bytes = if b_ptr.is_null() { + &[] + } else { + std::slice::from_raw_parts(string_data(b_ptr), (*b_ptr).byte_len as usize) + }; + return Some(match utf16_cmp_bytes(a_bytes, b_bytes) { + std::cmp::Ordering::Less => -1, + std::cmp::Ordering::Equal => 0, + std::cmp::Ordering::Greater => 1, + }); + } + } + if !a_value.is_any_string() || !b_value.is_any_string() { + return None; + } + Some(compare_inline_or_mixed_strings(a, b)) +} + +#[inline(never)] +fn compare_inline_or_mixed_strings(a: f64, b: f64) -> i32 { + if a.to_bits() == b.to_bits() { + return 0; + } + let mut a_scratch = [0; crate::value::SHORT_STRING_MAX_LEN]; + let mut b_scratch = [0; crate::value::SHORT_STRING_MAX_LEN]; + let (a_ptr, a_len) = crate::string::str_bytes_from_jsvalue(a, &mut a_scratch).unwrap(); + let (b_ptr, b_len) = crate::string::str_bytes_from_jsvalue(b, &mut b_scratch).unwrap(); + // There is no allocation or user-code window while these views are live. + // A null heap-string payload is the legacy empty view; from_raw_parts + // itself still requires a non-null pointer even for an empty slice. + let a_bytes = if a_len == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(a_ptr, a_len as usize) } + }; + let b_bytes = if b_len == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(b_ptr, b_len as usize) } + }; + match utf16_cmp_bytes(a_bytes, b_bytes) { + std::cmp::Ordering::Less => -1, + std::cmp::Ordering::Equal => 0, + std::cmp::Ordering::Greater => 1, + } +} + /// SSO-aware key match: compare a stored-key `JSValue` (which may be a /// `STRING_TAG` heap pointer OR a `SHORT_STRING_TAG` inline SSO value) /// against an incoming heap `*const StringHeader` key. @@ -876,6 +1006,23 @@ mod utf16_cmp_ascii_fast_path_tests { } } + #[test] + fn short_prefix_word_boundaries_match_full_utf16_order() { + let tails = corpus(); + for len in [0, 1, 5, 7, 8, 9, 15, 16, 23, 24, 31, 32, 33, 64] { + for prefix in [b"a".as_slice(), "é".as_bytes(), &[0xff]] { + let prefix: Vec = prefix.iter().copied().cycle().take(len).collect(); + for a in &tails { + for b in &tails { + let a = [prefix.as_slice(), a].concat(); + let b = [prefix.as_slice(), b].concat(); + assert_eq!(utf16_cmp_bytes(&a, &b), reference_cmp(&a, &b)); + } + } + } + } + } + /// Every shape the fast path has to get right or fall through on. /// WTF-8 lone surrogates are raw byte literals — they are not /// representable as Rust `&str`. diff --git a/crates/perry-runtime/src/string/mod.rs b/crates/perry-runtime/src/string/mod.rs index a284456320..3d5f479786 100644 --- a/crates/perry-runtime/src/string/mod.rs +++ b/crates/perry-runtime/src/string/mod.rs @@ -160,7 +160,8 @@ pub use compare::{ // `is_string() && js_string_equals(key, key_val.as_string_ptr())` shape // across object/. pub(crate) use compare::{ - js_string_key_bytes, js_string_key_matches, js_string_key_matches_bytes, utf16_cmp_bytes, + compare_primitive_strings, js_string_key_bytes, js_string_key_matches, + js_string_key_matches_bytes, utf16_cmp_bytes, }; pub use concat::{ js_string_add_value, js_string_append_chain, js_string_concat, js_string_concat_box, diff --git a/scripts/gc_root_dominance_check.py b/scripts/gc_root_dominance_check.py index d982dc1d45..c53170503a 100755 --- a/scripts/gc_root_dominance_check.py +++ b/scripts/gc_root_dominance_check.py @@ -485,6 +485,11 @@ def build_cfg(f): "js_gc_register_global_root", # pure value predicates / bit twiddling "js_is_truthy", "js_nanbox_get_pointer", + # string/compare.rs::js_string_compare -> utf16_cmp_bytes: pointer magnitude + # guards, immutable byte slices, bounded word scans and UTF-16 iterators. + # No allocations, locks, writes, coercions, or calls into the collector. + # The boxed js_string_compare_value does allocate and stays collecting. + "js_string_compare", # inline-cache guards: pure reads "js_typed_feedback_closure_direct_call_guard", "js_closure_exact_func_guard", "js_object_own_method_cache_miss", diff --git a/scripts/raw_handle_debt_baseline.txt b/scripts/raw_handle_debt_baseline.txt index a0394c20d6..43c16f4eb8 100644 --- a/scripts/raw_handle_debt_baseline.txt +++ b/scripts/raw_handle_debt_baseline.txt @@ -1 +1 @@ -949 +947 diff --git a/scripts/raw_handle_debt_files.txt b/scripts/raw_handle_debt_files.txt index 077278224c..124f039330 100644 --- a/scripts/raw_handle_debt_files.txt +++ b/scripts/raw_handle_debt_files.txt @@ -53,7 +53,7 @@ 2 crates/perry-runtime/src/array/iter_methods.rs 31 crates/perry-runtime/src/array/iterator.rs 12 crates/perry-runtime/src/array/push_pop.rs -14 crates/perry-runtime/src/array/sort.rs +12 crates/perry-runtime/src/array/sort.rs 12 crates/perry-runtime/src/async_hooks.rs 5 crates/perry-runtime/src/atomics.rs 7 crates/perry-runtime/src/builtins/console.rs diff --git a/scripts/shape_descriptor_census.py b/scripts/shape_descriptor_census.py index 104ec61dff..a254f7fb3d 100644 --- a/scripts/shape_descriptor_census.py +++ b/scripts/shape_descriptor_census.py @@ -234,6 +234,7 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: "crates/perry-runtime/src/object/exotic_expando.rs", "crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs", "crates/perry-runtime/src/object/field_get_set/ic_miss.rs", + "crates/perry-runtime/src/object/field_get_set/ic_miss/packed_get.rs", "crates/perry-runtime/src/proxy/put_value.rs", "crates/perry-runtime/src/gc/types.rs", "crates/perry-runtime/src/regex.rs", @@ -601,7 +602,23 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: ): raise CensusError("RegExp dispatch reintroduced the former object-kind probe") - read_miss = function_body(ic_miss, "js_object_get_field_ic_miss") + # Both exported read entries tail-call the shared implementation. Follow + # that implementation, and prove the wrappers cannot bypass its authority. + packed_get = clean[ + "crates/perry-runtime/src/object/field_get_set/ic_miss/packed_get.rs" + ] + for name, final_arg in ( + ("js_object_get_field_ic_miss", r"std::ptr::null\s*\(\s*\)"), + ("js_object_get_field_ic_miss_packed", r"packed"), + ): + wrapper = function_body(packed_get, name) + if not re.fullmatch( + r"\s*super::get_field_ic_miss_impl\s*\(\s*obj\s*,\s*key\s*," + r"\s*cache_slot\s*,\s*" + final_arg + r"\s*\)\s*", + wrapper, + ): + raise CensusError(f"{name} does not delegate to the shared read authority") + read_miss = function_body(ic_miss, "get_field_ic_miss_impl") for body, label in ( (read_miss, "read PIC miss"), (function_body(put_value, "js_put_value_set_ic_miss"), "static write PIC miss"), @@ -654,9 +671,42 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: ) require_code( generic_body, - r"icmp_ne\s*\(\s*I32\s*,\s*&pcid\s*,\s*\"0\"\s*\)", - "generic read PIC invalid-id fail-closed token", + r"icmp_ne\s*\(\s*I64\s*,\s*&packed_word\s*,\s*\"0\"\s*\)", + "generic read PIC empty compact-cache rejection", ) + # Invalid ShapeIds now fail closed at publication and exact cache matching. + # Keep both halves of that proof: the emitted guard consumes a nonempty + # packed word's exact stamp, and neither cache writer admits a zero stamp. + compact_guard = re.sub(r"\s+", "", generic_body) + for fragment in ( + 'letpacked_present=ctx.block().icmp_ne(I64,&packed_word,"0");', + 'letis_plain_object=ctx.block().and(I1,&is_plain_kind,&packed_present);', + 'cond_br(&is_plain_object,&tok_label,&desc_classify_label)', + 'letpacked_stamp=ctx.block().trunc(I64,&packed_word,I32);', + 'lettoken_eq=ctx.block().icmp_eq(I32,&pcid,&packed_stamp);', + 'cond_br(&token_eq,&hit_label,&token_miss_label)', + ): + if fragment not in compact_guard: + raise CensusError("generic read PIC compact identity guard disconnected: " + fragment) + prime = function_body(ic_miss, "pic_prime_get") + if not re.match( + r"\s*if\s+token\s*==\s*crate::object::shapes::PIC_ID_TOKEN_BIT\s+as\s+i64" + r"\s*\{\s*return;\s*\}", + prime, + ): + raise CensusError("read PIC publication must reject the zero-ShapeId token first") + require_code( + shapes, r"const\s+SHAPE_ID_BASE\s*:\s*u32\s*=\s*0x8000_0000\s*;", + "compact PIC ShapeId range excludes zero", + ) + packed_prime = re.sub(r"\s+", "", function_body(packed_get, "prime_get")) + range_guard = ( + "if!(crate::object::shapes::SHAPE_ID_BASE..crate::object::shapes::SHAPE_ID_END)" + ".contains(&stamp)||tokenasu64!=(stampasu64|crate::object::shapes::PIC_ID_TOKEN_BIT)" + "||!(0..=0x7fff_ffff).contains(&slot){return;}" + ) + if range_guard not in packed_prime or packed_prime.find("(*packed).store") < packed_prime.index(range_guard): + raise CensusError("compact read PIC publication lost its valid ShapeId/slot proof") for name in ("lower_put_value_static_write_ic", "lower_put_value_dyn_ic_inline"): body = function_body(raw_write_pics, name) if re.search(r"add\s*\(\s*I64\s*,\s*&(safe_target|t_handle)\s*,\s*\"(?:8|16)\"", body): @@ -757,6 +807,53 @@ def expect_rejected(label: str, check: Callable[[], None]) -> None: def run_sabotage_selftests(sources: dict[str, str], baseline: dict[str, object]) -> None: + packed_path = "crates/perry-runtime/src/object/field_get_set/ic_miss/packed_get.rs" + for entry in ("js_object_get_field_ic_miss", "js_object_get_field_ic_miss_packed"): + bypassed = dict(sources) + body = function_body(bypassed[packed_path], entry) + bypassed[packed_path] = bypassed[packed_path].replace( + body, "if false { " + body + "; } 0.0", 1 + ) + expect_rejected( + f"{entry} bypasses its shared implementation behind a dead call", + lambda: assert_authority_surfaces(bypassed), + ) + legacy_read = dict(sources) + path = "crates/perry-runtime/src/object/field_get_set/ic_miss.rs" + body = function_body(legacy_read[path], "get_field_ic_miss_impl") + legacy_read[path] = legacy_read[path].replace( + body, "let token = if use_shape { 0 } else { keys as u64 };\n" + body, 1 + ) + expect_rejected( + "shared read implementation reintroduces a keys-pointer token", + lambda: assert_authority_surfaces(legacy_read), + ) + for path, before, after, label in ( + ( + "crates/perry-runtime/src/object/field_get_set/ic_miss.rs", + "if token == crate::object::shapes::PIC_ID_TOKEN_BIT as i64", + "if token != crate::object::shapes::PIC_ID_TOKEN_BIT as i64", + "full-cache writer admits a zero ShapeId", + ), + ( + packed_path, + "if !(crate::object::shapes::SHAPE_ID_BASE..crate::object::shapes::SHAPE_ID_END)", + "if (crate::object::shapes::SHAPE_ID_BASE..crate::object::shapes::SHAPE_ID_END)", + "compact-cache writer inverts its valid ShapeId range", + ), + ( + "crates/perry-codegen/src/expr/property_get/generic_dispatch.rs", + "icmp_eq(I32, &pcid, &packed_stamp)", + "icmp_eq(I32, &pcid, &pcid)", + "compact-cache hit ignores the cached identity", + ), + ): + broken = dict(sources) + if broken[path].count(before) != 1: + raise CensusError("compact read PIC sabotage fixture missing: " + label) + broken[path] = broken[path].replace(before, after, 1) + expect_rejected(label, lambda: assert_authority_surfaces(broken)) + missing_authority = dict(sources) missing_authority.pop("crates/perry-runtime/src/object/shapes.rs") expect_rejected( @@ -913,17 +1010,17 @@ def run_sabotage_selftests(sources: dict[str, str], baseline: dict[str, object]) lambda: assert_authority_surfaces(legacy_ir), ) - # #8665: the generic read PIC's invalid-id fail-closed token (pcid != 0) - # must not go quietly missing. Plant a regression that emits an - # always-nonzero comparand instead of the real ShapeId register, and + # #8665: the generic read PIC's invalid-id proof must not go missing. + # Its nonzero check now applies to the packed cache word. Plant a + # regression that changes the rejected sentinel, and # prove the census still catches it -- this is what stands between the # check above and a vacuous pass, per #6942/#6946/#7024's precedent that # an unexercised assertion is a decision nobody actually made. dropped_fail_closed = dict(sources) path = "crates/perry-codegen/src/expr/property_get/generic_dispatch.rs" sabotaged_body, substitutions = re.subn( - r'icmp_ne\(I32, &pcid, "0"\)', - 'icmp_ne(I32, &pcid, "-1")', + r'icmp_ne\(I64, &packed_word, "0"\)', + 'icmp_ne(I64, &packed_word, "-1")', dropped_fail_closed[path], count=1, ) diff --git a/test-files/test_gap_array_adaptive_sort.ts b/test-files/test_gap_array_adaptive_sort.ts new file mode 100644 index 0000000000..cf20a8f008 --- /dev/null +++ b/test-files/test_gap_array_adaptive_sort.ts @@ -0,0 +1,218 @@ +// Generic comparator-sort coverage: stability, arbitrary values, callbacks, +// holes, exotic receivers, copying methods, coercion, and exception transport. +function check(ok: boolean, message: string) { + if (!ok) throw new Error(message); +} + +for (const size of [0, 1, 2, 31, 32, 33, 64, 65, 127, 513]) { + for (const pattern of [0, 1, 2, 3]) { + const records: { key: number; id: number; text: string }[] = []; + for (let i = 0; i < size; i++) { + let key = (i * 17) % 19; + if (pattern === 1) key = Math.floor((size - i) / 3); + if (pattern === 2) key = Math.floor(i / 3); + if (pattern === 3) key = 0; + records.push({ key, id: i, text: "item:" + i }); + } + const original = records.slice(); + const result = records.sort((a, b) => a.key - b.key); + check(result === records && result.length === size, "receiver identity/length"); + const seen: boolean[] = []; + for (let i = 0; i < size; i++) { + const item = result[i]; + check(item === original[item.id], "element identity"); + check(!seen[item.id], "permutation duplicate"); + seen[item.id] = true; + check(item.text === "item:" + item.id, "object identity"); + if (i > 0) { + const prev = result[i - 1]; + check(prev.key <= item.key, "sorted keys"); + check(prev.key !== item.key || prev.id < item.id, "stable equal keys"); + } + } + } +} +console.log("stable objects and run boundaries"); + +const mixed: any[] = []; +for (let i = 256; i >= 0; i--) { + mixed.push(i % 3 === 0 ? i : i % 3 === 1 ? String(i) : { key: i }); +} +function mixedKey(value: any): number { + return typeof value === "object" ? value.key : Number(value); +} +mixed.sort((a, b) => mixedKey(a) - mixedKey(b)); +for (let i = 0; i < mixed.length; i++) { + check(mixedKey(mixed[i]) === i, "mixed value permutation"); + check(typeof mixed[i] === (i % 3 === 0 ? "number" : i % 3 === 1 ? "string" : "object"), "mixed value type"); +} +console.log("mixed pointers and numbers"); + +const words = ["z", "a", "\u{1f600}", "aa", "a", "\uffff", ""]; +words.sort((a, b) => a < b ? -1 : a > b ? 1 : 0); +console.log("strings", JSON.stringify(words)); + +const equal = [4, 1, 3, 2]; +equal.sort(() => NaN); +check(equal.join(",") === "4,1,3,2", "NaN means equal"); +const coerced = [9, 3, 7, 1, 5]; +coerced.sort((a, b) => String(a - b) as any); +check(coerced.join(",") === "1,3,5,7,9", "comparator ToNumber"); +const coercionObjects = [8, 2, 6, 4]; +coercionObjects.sort((a, b) => ({ valueOf() { return a - b; } }) as any); +check(coercionObjects.join(",") === "2,4,6,8", "allocating result coercion"); +const infiniteResults = [3, 1, 2, 1]; +infiniteResults.sort((a, b) => a === b ? -0 : a < b ? -Infinity : Infinity); +check(infiniteResults.join(",") === "1,1,2,3", "signed zero and infinite comparator results"); +for (const result of [undefined, false, -0, NaN]) { + const ties = [3, 1, 2]; + ties.sort(() => result as any); + check(ties.join(",") === "3,1,2", "coerced equality preserves order"); +} +for (const result of [1n, Object(1n), { valueOf() { return 1n; } }, + { [Symbol.toPrimitive]() { return 1n; } }, Symbol("not-a-number")]) { + const input = [2, 1]; + let threw = false; + try { input.sort(() => result as any); } catch (error) { threw = error instanceof TypeError; } + check(threw && input.join(",") === "2,1", "invalid numeric result throws before publication"); +} +console.log("comparator coercion"); + +const sparse = [3, , undefined, 1, , 2]; +sparse.sort((a, b) => { + check(a !== undefined && b !== undefined, "undefined reached comparator"); + return (a as number) - (b as number); +}); +check(sparse.length === 6 && sparse.slice(0, 3).join(",") === "1,2,3", "sparse values"); +check(3 in sparse && sparse[3] === undefined && !(4 in sparse) && !(5 in sparse), "sparse suffix"); +const generic: any = { 0: 5, 2: 1, 3: 3, length: 4 }; +Array.prototype.sort.call(generic, (a: number, b: number) => a - b); +check(generic[0] === 1 && generic[1] === 3 && generic[2] === 5 && !(3 in generic), "array-like receiver"); +const source = [7, 1, 3]; +check(source.toSorted((a, b) => a - b).join(",") === "1,3,7", "toSorted result"); +check(source.join(",") === "7,1,3", "toSorted receiver"); +console.log("sparse and copying sorts"); + +const mutated = [6, 4, 2, 5, 3, 1]; +let mutationCalls = 0; +mutated.sort((a, b) => { + if (mutationCalls++ === 0) { + mutated[0] = 999; + const inner = ["d", "b", "c", "a"]; + inner.sort((x, y) => x < y ? -1 : x > y ? 1 : 0); + check(inner.join("") === "abcd", "reentrant sort"); + } + return a - b; +}); +check(mutated.join(",") === "1,2,3,4,5,6", "snapshot across mutation"); +console.log("mutating and reentrant comparators"); + +for (const action of ["shrink", "grow", "freeze", "getter"]) { + const receiver = [5, 4, 3, 2, 1]; + let changed = false; + let threw = false; + try { + receiver.sort((a, b) => { + if (!changed) { + changed = true; + if (action === "shrink") receiver.length = 0; + if (action === "grow") receiver.push(8, 9); + if (action === "freeze") Object.freeze(receiver); + if (action === "getter") Object.defineProperty(receiver, "0", { + get() { return 7; }, configurable: true, + }); + } + return a - b; + }); + } catch (error) { + check(error instanceof TypeError, "write-back must throw TypeError"); + threw = true; + } + check(threw === (action === "freeze" || action === "getter"), "write-back descriptor recheck"); + const expected = action === "shrink" ? "1,2,3,4,5" + : action === "grow" ? "1,2,3,4,5,8,9" + : action === "freeze" ? "5,4,3,2,1" : "7,4,3,2,1"; + check(receiver.join(",") === expected, "write-back after " + action); +} +console.log("receiver changes during sort"); + +const sealedDuringSort = [3, , 1]; +let sealedOnce = false; +let deletionThrew = false; +try { + sealedDuringSort.sort((a, b) => { + if (!sealedOnce) { + sealedOnce = true; + sealedDuringSort[1] = 7; + Object.seal(sealedDuringSort); + } + return a - b; + }); +} catch (error) { + deletionThrew = error instanceof TypeError; +} +check(deletionThrew && sealedDuringSort.join(",") === "1,3,1", "strict deletion after callback sealing"); +console.log("strict sorted suffix deletion"); + +// The final write-back can call setters after the comparator has finished. +// Keep the private source and index workspace valid across those allocations. +const setterRows: any[] = []; +for (let i = 0; i < 128; i++) setterRows.push({key: 127 - i}); +const originalSetterRows = setterRows.slice(); +let setterSeen: any; +let setterInstalled = false; +setterRows.sort((a, b) => { + if (!setterInstalled) { + setterInstalled = true; + Object.defineProperty(setterRows, "0", { + configurable: true, + set(value) { + setterSeen = value; + const pressure: any[] = []; + for (let j = 0; j < 256; j++) pressure.push({j, text: "write-back-" + j}); + check(pressure[255].j === 255, "setter allocations"); + } + }); + } + return a.key - b.key; +}); +check(setterSeen === originalSetterRows[127], "setter receives sorted identity"); +for (let i = 1; i < 128; i++) { + check(setterRows[i] === originalSetterRows[127 - i], "sorted identities after allocating setter"); +} +console.log("allocating setter write-back"); + +for (const define of [false, true]) { + const proto: any = Object.prototype; + if (define) Object.defineProperty(proto, "1", {value: 2, writable: true, configurable: true}); + else proto[1] = 2; + const inherited = [3, , 1]; + try { + inherited.sort((a, b) => a - b); + } finally { + delete proto[1]; + } + check(inherited.join(",") === "1,2,3", "indexed Object.prototype admission"); +} +console.log("indexed prototype sorting"); + +for (const throwAfter of [1, 17, 100]) { + const throwing: number[] = []; + for (let i = 0; i < 257; i++) throwing.push((i * 73) % 257); + const before = throwing.join(","); + const marker = { name: "sort exception", throwAfter }; + let calls = 0; + let caught = false; + try { + throwing.sort((a, b) => { + if (++calls === throwAfter) throw marker; + return a - b; + }); + } catch (error) { + caught = error === marker; + } + check(caught && throwing.join(",") === before, "throw identity and unpublished result"); + throwing.sort((a, b) => a - b); + for (let i = 0; i < throwing.length; i++) check(throwing[i] === i, "sort after exception"); +} +console.log("throwing comparators and recovery"); diff --git a/test-files/test_gap_dynamic_property_cache_guards.ts b/test-files/test_gap_dynamic_property_cache_guards.ts new file mode 100644 index 0000000000..9dc263d2f2 --- /dev/null +++ b/test-files/test_gap_dynamic_property_cache_guards.ts @@ -0,0 +1,34 @@ +// Reuse each generic read site across shapes, prototypes and descriptors. +function readKey(value: any): any { return value.key; } +const a: any = {key: 1}; +const b: any = {extra: 2, key: 3}; +const c: any = {first: 4, second: 5, key: 6}; +for (let i = 0; i < 20; i++) { + if (readKey(a) !== 1 || readKey(b) !== 3 || readKey(c) !== 6) throw new Error("plain cache"); +} +const inherited: any = Object.create({key: 99}); +for (let i = 0; i < 5; i++) console.log(readKey(a), readKey(inherited), readKey(b)); +let calls = 0; +Object.defineProperty(a, "key", {get() { calls++; return 10 + calls; }, configurable: true}); +console.log(readKey(a), readKey(b), readKey(a), calls); +delete b.key; +console.log(readKey(b), readKey(inherited)); +b.key = 31; +console.log(readKey(b)); +const proxy: any = new Proxy(c, {get(target, key) { calls++; return Reflect.get(target, key); }}); +console.log(readKey(proxy), readKey(c), calls); +function readLength(value: any): any { return value.length; } +const lengthObject: any = {length: 77}; +console.log(readLength(lengthObject), readLength([1, 2]), readLength("hello"), readLength(lengthObject)); +function readSize(value: any): any { return value.size; } +const sized: any = {size: 8}; +console.log(readSize(sized), readSize(new Map([[1, 2]])), readSize(new Set([1, 2])), readSize(sized)); +const wide: any = {}; +for (let i = 0; i < 100; i++) wide["field" + i] = i; +wide.key = 42; +for (let i = 0; i < 20; i++) if (readKey(wide) !== 42) throw new Error("overflow cache"); +delete wide.key; +console.log(readKey(wide)); +wide.key = 43; +console.log(readKey(wide), readKey(inherited)); +console.log("dynamic property cache guards ok"); diff --git a/test-files/test_gap_numeric_tag_guard.ts b/test-files/test_gap_numeric_tag_guard.ts new file mode 100644 index 0000000000..d5c70e0b36 --- /dev/null +++ b/test-files/test_gap_numeric_tag_guard.ts @@ -0,0 +1,22 @@ +function arithmetic(a: any, b: any) { + return [a + b, a - b, a * b, a / b, a % b, a ** b].map(String).join("|"); +} +const values: any[] = [0, -0, 1, -1, 1.25, -1.25, 1e308, -1e308, + Number.MIN_VALUE, -Number.MIN_VALUE, Infinity, -Infinity, NaN, + "2", "-2", "", undefined, null, false, true]; +function comparisons(a: any, b: any) { + return [a < b, a <= b, a > b, a >= b, a === b, a !== b, a == b, a != b].join("|"); +} +for (const a of values) { + for (const b of values) console.log(arithmetic(a, b), comparisons(a, b)); +} +console.log(arithmetic(3n, 2n)); +try { console.log(arithmetic(3n, 2)); } catch (e) { console.log(e instanceof TypeError); } +let calls: string[] = []; +const a = {[Symbol.toPrimitive](hint: string) { calls.push("a:" + hint); return 7; }}; +const b = {[Symbol.toPrimitive](hint: string) { calls.push("b:" + hint); return 2; }}; +console.log(arithmetic(a, b)); +console.log(calls.join(",")); +const marker = {}; +const throwing = {[Symbol.toPrimitive]() { throw marker; }}; +try { console.log(arithmetic(1, throwing)); } catch (e) { console.log(e === marker); } diff --git a/test-files/test_gap_primitive_string_relational.ts b/test-files/test_gap_primitive_string_relational.ts new file mode 100644 index 0000000000..e56e4d2fdc --- /dev/null +++ b/test-files/test_gap_primitive_string_relational.ts @@ -0,0 +1,32 @@ +function relations(a: any, b: any) { + return [a < b, a > b, a <= b, a >= b]; +} +const strings = JSON.parse('["","a","ab","abcde","abcdefghij","é","𐀀",""]'); +for (const a of strings) { + for (const b of strings) console.log(JSON.stringify([a, b, relations(a, b)])); +} +for (const a of ["2", "10", "", "NaN", "-1"]) { + for (const b of [2, 10, null, undefined, true, NaN]) { + console.log(JSON.stringify([a, b, relations(a, b), relations(b, a)])); + } +} +let log: string[] = []; +const left = {[Symbol.toPrimitive](hint: string) { log.push("left:" + hint); return "a"; }}; +const right = {[Symbol.toPrimitive](hint: string) { log.push("right:" + hint); return "b"; }}; +console.log(JSON.stringify(relations(left, right)), log.join(",")); +log = []; +console.log(JSON.stringify(relations(left, "b")), log.join(",")); +const marker = {marker: true}; +const throwing = {[Symbol.toPrimitive]() { throw marker; }}; +try { console.log(throwing < "b"); } catch (e) { console.log(e === marker); } + +const boundaries: any[] = ["abcdefg𐀀", "abcdefg\uE000", "abcd\u0000efgh", "abcdefgéx"]; +const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; +for (const length of [7, 8, 9, 15, 16, 17, 32]) { + const base = alphabet.slice(0, length); + boundaries.push(base, base + "q", base.slice(0, length - 1) + "Z"); + if (length >= 8) boundaries.push(base.slice(0, 7) + "Z" + base.slice(8)); +} +for (const a of boundaries) { + for (const b of boundaries) console.log(JSON.stringify([a, b, relations(a, b)])); +}