From 0c5348ce5694654d8aa6e4477900ebc9fffe67de Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Fri, 11 Sep 2026 15:00:50 +0200 Subject: [PATCH 1/3] fix(string): preserve UTF-16 slices and scalarize suffix parsing --- benchmarks/string_slice/.gitignore | 4 + benchmarks/string_slice/README.md | 79 +++++++ .../string_slice/baseline-artifacts.json | 5 + benchmarks/string_slice/baseline.json | 176 +++++++++++++++ benchmarks/string_slice/measure.py | 70 ++++++ .../string_slice/public-baseline-check.json | 6 + .../string_slice/string-slice-astral.ts | 10 + .../string-slice-parse-loop-ascii.ts | 93 ++++++++ .../string-slice-parse-loop-unicode.ts | 93 ++++++++ changelog.d/10061-string-slice.md | 9 + crates/perry-codegen/src/codegen/closure.rs | 2 + crates/perry-codegen/src/codegen/entry.rs | 4 + crates/perry-codegen/src/codegen/function.rs | 2 + crates/perry-codegen/src/codegen/method.rs | 4 + .../perry-codegen/src/collectors/hir_facts.rs | 7 + crates/perry-codegen/src/collectors/mod.rs | 1 + .../src/collectors/suffix_strings.rs | 211 ++++++++++++++++++ .../src/collectors/suffix_strings/tests.rs | 97 ++++++++ crates/perry-codegen/src/expr/dispatch.rs | 3 + crates/perry-codegen/src/expr/mod.rs | 5 + .../perry-codegen/src/expr/suffix_cursor.rs | 97 ++++++++ .../src/runtime_decls/strings.rs | 7 + crates/perry-codegen/src/stmt/mod.rs | 8 +- .../gc/tests/runtime_roots/string_slice.rs | 44 ++++ crates/perry-runtime/src/string/mod.rs | 4 + crates/perry-runtime/src/string/slice_ops.rs | 57 +---- .../perry-runtime/src/string/slice_range.rs | 78 +++++++ .../perry-runtime/src/string/slice_tests.rs | 127 +++++++++++ .../perry-runtime/src/string/suffix_cursor.rs | 116 ++++++++++ .../test_gap_gc_string_suffix_cursor.ts | 28 +++ .../test_gap_string_slice_utf16_suffix.ts | 59 +++++ test-parity/gc_repsel_corpus.txt | 2 + 32 files changed, 1454 insertions(+), 54 deletions(-) create mode 100644 benchmarks/string_slice/.gitignore create mode 100644 benchmarks/string_slice/README.md create mode 100644 benchmarks/string_slice/baseline-artifacts.json create mode 100644 benchmarks/string_slice/baseline.json create mode 100644 benchmarks/string_slice/measure.py create mode 100644 benchmarks/string_slice/public-baseline-check.json create mode 100644 benchmarks/string_slice/string-slice-astral.ts create mode 100644 benchmarks/string_slice/string-slice-parse-loop-ascii.ts create mode 100644 benchmarks/string_slice/string-slice-parse-loop-unicode.ts create mode 100644 changelog.d/10061-string-slice.md create mode 100644 crates/perry-codegen/src/collectors/suffix_strings.rs create mode 100644 crates/perry-codegen/src/collectors/suffix_strings/tests.rs create mode 100644 crates/perry-codegen/src/expr/suffix_cursor.rs create mode 100644 crates/perry-runtime/src/string/slice_range.rs create mode 100644 crates/perry-runtime/src/string/slice_tests.rs create mode 100644 crates/perry-runtime/src/string/suffix_cursor.rs create mode 100644 test-files/test_gap_gc_string_suffix_cursor.ts create mode 100644 test-files/test_gap_string_slice_utf16_suffix.ts diff --git a/benchmarks/string_slice/.gitignore b/benchmarks/string_slice/.gitignore new file mode 100644 index 0000000000..14c434901e --- /dev/null +++ b/benchmarks/string_slice/.gitignore @@ -0,0 +1,4 @@ +*.exe +string-slice-parse-loop-ascii +string-slice-parse-loop-unicode +__pycache__/ diff --git a/benchmarks/string_slice/README.md b/benchmarks/string_slice/README.md new file mode 100644 index 0000000000..518abff5ab --- /dev/null +++ b/benchmarks/string_slice/README.md @@ -0,0 +1,79 @@ +# String suffix parsing (#10061) + +The three TypeScript sources are copied unchanged from [issue #10061](https://github.com/PerryTS/perry/issues/10061). +`measure.py` runs each engine serially, with the issue's five input sizes and +60-second process timeout. The workload itself checks every warmup and measured +checksum, warms for at least 200 ms and five invocations, and reports the median +of seven samples of at least 20 ms each. Input setup remains outside the timer. + +## Implementation and limits + +Materialized `slice`, `substring`, and `substr` now share a bounded WTF-8 boundary +walker. A boundary between the two UTF-16 units of an astral scalar retains the +requested high or low surrogate, encoded as WTF-8. Result byte length, UTF-16 +length, and lone-surrogate flags agree. Complete byte ranges use the existing +rooted copy; split boundaries are assembled in Rust-owned memory before any +destination allocation can move or collect the source. + +The native compiler also keeps an eligible suffix local as its ordinary rooted +source plus three scalar offsets on the stack. `s = s.slice(k)` advances that +cursor, and `.length`/`charCodeAt(i)` read relative to it. The byte cursor can stop +between an astral scalar's surrogate halves. Consuming the whole input has linear +total decoding work and performs no substring allocations or suffix copies. + +Eligibility is conservative: a mutable local declaration in the function's outer +statement list, only discarded self-assignments from `slice` with an omitted or +nonnegative constant start, and only length and constant-index `charCodeAt` +consumers. Return values, aliases, captures, other writes, negative/dynamic slice +bounds, an explicit end, and other string consumers keep ordinary materialized +strings. A runtime string-tag guard preserves the existing property/method path +when a TypeScript string annotation actually holds a different kind of value. +This is compiler scalar replacement, not a new public string representation; +the flat string layout and FFI ABI are unchanged. Unselected loops can still +incur repeated suffix copying; general escaping substring views are separate +representation work. + +Memory policy: an eligible cursor retains its original source through the +ordinary local GC root until that root is released; its state contains no +interior pointers. It creates no shared backing-store chain, cache, or persistent +GC root. A small materialized slice owns its bytes and retains no source string. +Moving-GC coverage asserts that the source really relocates while a cursor sits +between surrogate halves, and that a separately retained slice remains valid. +The compiled stress fixture overwrites the original binding and allocates inside +the parse loop. + +The change leaves trim operations (#10054), general Unicode random indexing +(#10055), and HIR `for-of` iteration stride (#10062) independent. + +## Reproduction + +Base: `603b074ace01464bc66fc07cc8d532f26ccf5a0f` (pristine main), Perry +`0.5.1532`; Node `v26.5.1`; native Windows x64. Compiler and both matching static +archives were built together with: + +```powershell +$env:LLVM_SYS_221_PREFIX = 'C:\llvm' +$env:CARGO_PROFILE_RELEASE_CODEGEN_UNITS = '16' +cargo build --release --locked -j 6 -p perry -p perry-runtime-static -p perry-stdlib-static +$env:PATH = 'C:\llvm\bin;' + $env:PATH +$env:PERRY_RUNTIME_DIR = (Resolve-Path target/release).Path +python benchmarks/string_slice/measure.py --perry target/release/perry.exe --output benchmarks/string_slice/fixed.json +``` + +The release optimization level and thin LTO are unchanged; 16 codegen units are +used in both arms. `baseline-artifacts.json` records the pristine compiler and +archive hashes. Result files include source hashes and engine versions. Timing +runs are serialized with each other and with local builds; this is a shared +development host, so constant factors are diagnostic rather than a quiet-host +performance claim. Unicode timings are interpreted only after checksum parity. + +The pristine reduction reproduces the issue exactly: + +```text +Perry: 5:228,4:20013,3:55357,2:195,1:150, +Node: 5:228,4:20013,3:55357,2:56832,1:214, +``` + +The baseline ASCII exponent is 2.013 over completed sizes 100–10,000; 100,000 +times out. Unicode fails checksum stability at 100 and mismatches at 1,000 and +10,000, so its baseline speed is not classified. diff --git a/benchmarks/string_slice/baseline-artifacts.json b/benchmarks/string_slice/baseline-artifacts.json new file mode 100644 index 0000000000..6ade9768e9 --- /dev/null +++ b/benchmarks/string_slice/baseline-artifacts.json @@ -0,0 +1,5 @@ +{ + "target/release/perry.exe": "bd112c9c1394946cab99671d9494bdbd723092ed8d2594ba6b6bc78e354a79ce", + "target/release/perry_runtime.lib": "50a570b026314a0b60271749ef1328166d211f8aecf5f263e4029e52b8c39286", + "target/release/perry_stdlib.lib": "2e6dc08e44d008a6e49947faa14873e6dec67ba1557238a0566913e823f88de3" +} diff --git a/benchmarks/string_slice/baseline.json b/benchmarks/string_slice/baseline.json new file mode 100644 index 0000000000..ec5e9ca0df --- /dev/null +++ b/benchmarks/string_slice/baseline.json @@ -0,0 +1,176 @@ +{ + "revision": "603b074ace01464bc66fc07cc8d532f26ccf5a0f", + "node": "v26.5.1", + "workloads": { + "ascii": { + "sha256": "3895cd80f04d760f98c3447607347ff5ea0c8d900129a187ddfd6f55be100bfb", + "engines": { + "node": [ + { + "name": "string-slice-parse-loop-ascii", + "category": "strings", + "n": 100, + "ms_per_run": 0.006702244556114048, + "runs": 19849, + "checksum": 464151292 + }, + { + "name": "string-slice-parse-loop-ascii", + "category": "strings", + "n": 1000, + "ms_per_run": 0.08602875536480824, + "runs": 1647, + "checksum": 710929850 + }, + { + "name": "string-slice-parse-loop-ascii", + "category": "strings", + "n": 10000, + "ms_per_run": 0.8166440000000057, + "runs": 174, + "checksum": 535454277 + }, + { + "name": "string-slice-parse-loop-ascii", + "category": "strings", + "n": 100000, + "ms_per_run": 7.339999999999994, + "runs": 21, + "checksum": 35382078 + }, + { + "name": "string-slice-parse-loop-ascii", + "category": "strings", + "n": 1000000, + "ms_per_run": 73.84990000000005, + "runs": 7, + "checksum": 153135489 + } + ], + "perry": [ + { + "name": "string-slice-parse-loop-ascii", + "category": "strings", + "n": 100, + "ms_per_run": 0.015718067556952185, + "runs": 8805, + "checksum": 464151292, + "checksum_match": true + }, + { + "name": "string-slice-parse-loop-ascii", + "category": "strings", + "n": 1000, + "ms_per_run": 1.3900666666666666, + "runs": 105, + "checksum": 710929850, + "checksum_match": true + }, + { + "name": "string-slice-parse-loop-ascii", + "category": "strings", + "n": 10000, + "ms_per_run": 166.93410000000017, + "runs": 7, + "checksum": 535454277, + "checksum_match": true + }, + { + "n": 100000, + "status": "TIMEOUT" + } + ] + }, + "slopes": { + "node": 1.0015311702090146, + "perry": 2.0130729544915633 + } + }, + "unicode": { + "sha256": "ca1ea8b7ce414346c145e60062c2dbe2be6209f78e203df1567f9f86da9efedb", + "engines": { + "node": [ + { + "name": "string-slice-parse-loop-unicode", + "category": "strings", + "n": 100, + "ms_per_run": 0.010193883792049139, + "runs": 13930, + "checksum": 319467163 + }, + { + "name": "string-slice-parse-loop-unicode", + "category": "strings", + "n": 1000, + "ms_per_run": 0.10086180904522792, + "runs": 1374, + "checksum": 431622199 + }, + { + "name": "string-slice-parse-loop-unicode", + "category": "strings", + "n": 10000, + "ms_per_run": 1.028454999999991, + "runs": 140, + "checksum": 36132863 + }, + { + "name": "string-slice-parse-loop-unicode", + "category": "strings", + "n": 100000, + "ms_per_run": 8.99769999999999, + "runs": 21, + "checksum": 49951631 + }, + { + "name": "string-slice-parse-loop-unicode", + "category": "strings", + "n": 1000000, + "ms_per_run": 91.22919999999999, + "runs": 7, + "checksum": 481167302 + } + ], + "perry": [ + { + "n": 100, + "status": "ERROR", + "exit": 1, + "stdout": "", + "stderr": "Error: CORRECTNESS: unstable checksum during warmup\n at \n" + }, + { + "name": "string-slice-parse-loop-unicode", + "category": "strings", + "n": 1000, + "ms_per_run": 14.891149999999982, + "runs": 14, + "checksum": 399614474, + "checksum_match": false, + "status": "CHECKSUM_MISMATCH" + }, + { + "name": "string-slice-parse-loop-unicode", + "category": "strings", + "n": 10000, + "ms_per_run": 1736.2381999999998, + "runs": 7, + "checksum": 455826758, + "checksum_match": false, + "status": "CHECKSUM_MISMATCH" + }, + { + "n": 100000, + "status": "TIMEOUT" + } + ] + }, + "slopes": { + "node": 0.9853993131693931, + "perry": null + } + } + }, + "platform": "Windows-11-10.0.26200-SP0", + "processor": "AMD64 Family 25 Model 116 Stepping 1, AuthenticAMD" +} diff --git a/benchmarks/string_slice/measure.py b/benchmarks/string_slice/measure.py new file mode 100644 index 0000000000..a37c98f35c --- /dev/null +++ b/benchmarks/string_slice/measure.py @@ -0,0 +1,70 @@ +"""Run the unchanged #10061 workloads sequentially against Node and Perry.""" +import argparse +import hashlib +import json +import math +import os +import platform +from pathlib import Path +import statistics +import subprocess + + +def slope(rows): + pairs = [(math.log(r["n"]), math.log(r["ms_per_run"])) for r in rows + if "ms_per_run" in r and "status" not in r] + if len(pairs) < 2: + return None + mx = statistics.mean(x for x, _ in pairs) + my = statistics.mean(y for _, y in pairs) + return sum((x - mx) * (y - my) for x, y in pairs) / sum((x - mx) ** 2 for x, _ in pairs) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--perry", required=True) + parser.add_argument("--output", required=True) + parser.add_argument("--skip-compile", action="store_true") + args = parser.parse_args() + folder = Path(__file__).resolve().parent + exe_suffix = ".exe" if os.name == "nt" else "" + result = {"revision": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), + "node": subprocess.check_output(["node", "--version"], text=True).strip(), + "platform": platform.platform(), "processor": platform.processor(), "workloads": {}} + for variant in ["ascii", "unicode"]: + name = "string-slice-parse-loop-" + variant + source = folder / (name + ".ts") + binary = folder / (name + exe_suffix) + if not args.skip_compile: + subprocess.run([args.perry, "compile", str(source), "--no-auto-optimize", "-o", str(binary)], check=True) + engines = {} + for n in [100, 1000, 10000, 100000, 1000000]: + for engine, command in [("node", ["node", str(source)]), ("perry", [str(binary)])]: + rows = engines.setdefault(engine, []) + if any(r.get("status") == "TIMEOUT" for r in rows): + continue + try: + run = subprocess.run(command + [str(n)], capture_output=True, text=True, timeout=60) + if run.returncode: + row = {"n": n, "status": "ERROR", "exit": run.returncode, + "stdout": run.stdout, "stderr": run.stderr} + else: + row = json.loads(run.stdout) + except subprocess.TimeoutExpired: + row = {"n": n, "status": "TIMEOUT"} + if engine == "perry" and "checksum" in row: + oracle = next((r for r in engines.get("node", []) if r["n"] == n), {}) + if "checksum" in oracle: + row["checksum_match"] = row["checksum"] == oracle["checksum"] + if not row["checksum_match"]: + row["status"] = "CHECKSUM_MISMATCH" + rows.append(row) + print(variant, engine, row, flush=True) + result["workloads"][variant] = {"sha256": hashlib.sha256(source.read_bytes()).hexdigest(), + "engines": engines, + "slopes": {e: slope(rs) for e, rs in engines.items()}} + Path(args.output).write_text(json.dumps(result, indent=2) + "\n", encoding="utf8") + + +if __name__ == "__main__": + main() diff --git a/benchmarks/string_slice/public-baseline-check.json b/benchmarks/string_slice/public-baseline-check.json new file mode 100644 index 0000000000..a5b73b0b77 --- /dev/null +++ b/benchmarks/string_slice/public-baseline-check.json @@ -0,0 +1,6 @@ +{ + "base": "603b074ace01464bc66fc07cc8d532f26ccf5a0f", + "benchmark_inputs_identical_to_base": true, + "source_fingerprint": "9507434be47f7bb383c30810bdc5d66d9be65290da529f38b7473860ea98f75e", + "harness_fingerprint": "513dba8ff9eaf931edc8a5fc01a0093a1156c31b0b6c9cc1218f710405e315e0" +} diff --git a/benchmarks/string_slice/string-slice-astral.ts b/benchmarks/string_slice/string-slice-astral.ts new file mode 100644 index 0000000000..b34a5da9f1 --- /dev/null +++ b/benchmarks/string_slice/string-slice-astral.ts @@ -0,0 +1,10 @@ +function inspect(input: string): string { + let s = input; + let out = ""; + for (let i = 0; i < 16 && s.length; i++) { + out += s.length + ":" + s.charCodeAt(0) + ","; + s = s.slice(1); + } + return out; +} +console.log(inspect("Γ€δΈ­πŸ˜€Γ–")); diff --git a/benchmarks/string_slice/string-slice-parse-loop-ascii.ts b/benchmarks/string_slice/string-slice-parse-loop-ascii.ts new file mode 100644 index 0000000000..c11cbd0851 --- /dev/null +++ b/benchmarks/string_slice/string-slice-parse-loop-ascii.ts @@ -0,0 +1,93 @@ +// @runtime {"name": "string-slice-parse-loop-ascii", "category": "strings", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/string/slice_ops.rs", "function": "js_string_slice"}, {"file": "crates/perry-runtime/src/string/mod.rs", "function": "string_copy_range"}], "hypothesis": "Hypothesis: each slice allocates and copies the remaining suffix, so removing one code unit per iteration copies a quadratic total number of bytes.", "notes": "ascii variant. n counts repeated input tokens, not bytes; UTF-16 length and UTF-8 byte length differ for Unicode. String result hashes sample roughly 32 positions for long strings (at most 63 for short strings), plus length. Reads one UTF-16 unit then removes it, including individual emoji surrogate halves; no manual optimized parser. charCodeAt(0) inspects only the current leading code unit, so the checksum itself does not scan a growing prefix. For Unicode, an offset inside an emoji must retain its low surrogate; the source byte-offset helper instead rounds beyond the complete code point.", "asynchronous": false, "output_stderr": false, "fresh_input": false} +// Standalone file. Shared helpers/driver are inlined by common.py. + +let seed = 0x12345678; +function rnd(): number { + seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; + return (seed >>> 0) / 4294967296; +} +function numbers(n: number): number[] { + const a: number[] = []; + for (let i = 0; i < n; i++) a.push(Math.floor(rnd() * 1000000)); + return a; +} +function hashArray(a: number[]): number { + let h = a.length; + for (let i = 0; i < a.length; i++) h = (h * 31 + a[i]) % 1000000007; + return h; +} +// Bounded checksum work avoids making string slicing/indexing part of every +// string benchmark's asymptotic cost. The workload itself consumes its result. +function hashString(s: string): number { + let h = s.length; + const step = Math.max(1, Math.floor(s.length / 32)); + for (let i = 0; i < s.length; i += step) h = (h * 31 + s.charCodeAt(i)) % 1000000007; + return h; +} + +function setup(n: number): string { return "aBcD".repeat(n); } +function run(input: string): number { + let s = input; + let h = 0; + while (s.length) { + h = (h * 31 + s.charCodeAt(0)) % 1000000007; + s = s.slice(1); + } + return h; +} + +// Size is the final argument: both native Perry and Node expose it reliably. +const n = Number(process.argv[process.argv.length - 1]); +if (!(n > 0)) throw new Error("Expected a positive size argument"); +function benchmarkMain(): void { + seed = 0x12345678; + const preparedInput = setup(n); + let checksum = 0; + let seen = false; + let warmMs = 0; + let warmRuns = 0; + while (warmMs < 200 || warmRuns < 5) { + seed = 0x12345678; + const input = preparedInput; + const start = performance.now(); + const value = run(input); + const elapsed = performance.now() - start; + if (!(elapsed >= 0)) throw new Error("Invalid monotonic timer"); + warmMs += elapsed; + warmRuns++; + if (seen && value !== checksum) throw new Error("CORRECTNESS: unstable checksum during warmup"); + checksum = value; + seen = true; + } + const samples: number[] = []; + let runs = 0; + for (let sample = 0; sample < 7; sample++) { + let elapsed = 0; + let count = 0; + // Mutable workloads prepare fresh input BEFORE each timer; immutable + // workloads reuse setup. Neither preparation nor validation is measured. + while (elapsed < 20) { + seed = 0x12345678; + const input = preparedInput; + const start = performance.now(); + const value = run(input); + const duration = performance.now() - start; + if (!(duration >= 0)) throw new Error("Invalid monotonic timer"); + elapsed += duration; + count++; + if (value !== checksum) throw new Error("CORRECTNESS: unstable checksum during sampling"); + } + samples.push(elapsed / count); + runs += count; + } + // Do not depend on Array.sort to compute the median of a sort benchmark. + for (let i = 1; i < samples.length; i++) { + const v = samples[i]; + let j = i - 1; + while (j >= 0 && samples[j] > v) { samples[j + 1] = samples[j]; j--; } + samples[j + 1] = v; + } + console.log(JSON.stringify({name: "string-slice-parse-loop-ascii", category: "strings", n, + ms_per_run: samples[3], runs, checksum})); +} +benchmarkMain(); diff --git a/benchmarks/string_slice/string-slice-parse-loop-unicode.ts b/benchmarks/string_slice/string-slice-parse-loop-unicode.ts new file mode 100644 index 0000000000..eea49ef247 --- /dev/null +++ b/benchmarks/string_slice/string-slice-parse-loop-unicode.ts @@ -0,0 +1,93 @@ +// @runtime {"name": "string-slice-parse-loop-unicode", "category": "strings", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/string/slice_ops.rs", "function": "js_string_slice"}, {"file": "crates/perry-runtime/src/string/mod.rs", "function": "string_copy_range"}, {"file": "crates/perry-runtime/src/string/mod.rs", "function": "utf16_offset_to_byte_offset"}], "hypothesis": "Hypothesis: utf16_offset_to_byte_offset advances over a whole astral character when slice starts between its surrogate halves; js_string_slice then copies the remaining bytes while stamping end-start as UTF-16 length, creating a payload/header mismatch. Repeated full-suffix copies also give quadratic work.", "notes": "unicode variant. n counts repeated input tokens, not bytes; UTF-16 length and UTF-8 byte length differ for Unicode. String result hashes sample roughly 32 positions for long strings (at most 63 for short strings), plus length. Reads one UTF-16 unit then removes it, including individual emoji surrogate halves; no manual optimized parser. charCodeAt(0) inspects only the current leading code unit, so the checksum itself does not scan a growing prefix. For Unicode, an offset inside an emoji must retain its low surrogate; the source byte-offset helper instead rounds beyond the complete code point.", "asynchronous": false, "output_stderr": false, "fresh_input": false} +// Standalone file. Shared helpers/driver are inlined by common.py. + +let seed = 0x12345678; +function rnd(): number { + seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; + return (seed >>> 0) / 4294967296; +} +function numbers(n: number): number[] { + const a: number[] = []; + for (let i = 0; i < n; i++) a.push(Math.floor(rnd() * 1000000)); + return a; +} +function hashArray(a: number[]): number { + let h = a.length; + for (let i = 0; i < a.length; i++) h = (h * 31 + a[i]) % 1000000007; + return h; +} +// Bounded checksum work avoids making string slicing/indexing part of every +// string benchmark's asymptotic cost. The workload itself consumes its result. +function hashString(s: string): number { + let h = s.length; + const step = Math.max(1, Math.floor(s.length / 32)); + for (let i = 0; i < s.length; i += step) h = (h * 31 + s.charCodeAt(i)) % 1000000007; + return h; +} + +function setup(n: number): string { return "Γ€δΈ­πŸ˜€Γ–".repeat(n); } +function run(input: string): number { + let s = input; + let h = 0; + while (s.length) { + h = (h * 31 + s.charCodeAt(0)) % 1000000007; + s = s.slice(1); + } + return h; +} + +// Size is the final argument: both native Perry and Node expose it reliably. +const n = Number(process.argv[process.argv.length - 1]); +if (!(n > 0)) throw new Error("Expected a positive size argument"); +function benchmarkMain(): void { + seed = 0x12345678; + const preparedInput = setup(n); + let checksum = 0; + let seen = false; + let warmMs = 0; + let warmRuns = 0; + while (warmMs < 200 || warmRuns < 5) { + seed = 0x12345678; + const input = preparedInput; + const start = performance.now(); + const value = run(input); + const elapsed = performance.now() - start; + if (!(elapsed >= 0)) throw new Error("Invalid monotonic timer"); + warmMs += elapsed; + warmRuns++; + if (seen && value !== checksum) throw new Error("CORRECTNESS: unstable checksum during warmup"); + checksum = value; + seen = true; + } + const samples: number[] = []; + let runs = 0; + for (let sample = 0; sample < 7; sample++) { + let elapsed = 0; + let count = 0; + // Mutable workloads prepare fresh input BEFORE each timer; immutable + // workloads reuse setup. Neither preparation nor validation is measured. + while (elapsed < 20) { + seed = 0x12345678; + const input = preparedInput; + const start = performance.now(); + const value = run(input); + const duration = performance.now() - start; + if (!(duration >= 0)) throw new Error("Invalid monotonic timer"); + elapsed += duration; + count++; + if (value !== checksum) throw new Error("CORRECTNESS: unstable checksum during sampling"); + } + samples.push(elapsed / count); + runs += count; + } + // Do not depend on Array.sort to compute the median of a sort benchmark. + for (let i = 1; i < samples.length; i++) { + const v = samples[i]; + let j = i - 1; + while (j >= 0 && samples[j] > v) { samples[j + 1] = samples[j]; j--; } + samples[j + 1] = v; + } + console.log(JSON.stringify({name: "string-slice-parse-loop-unicode", category: "strings", n, + ms_per_run: samples[3], runs, checksum})); +} +benchmarkMain(); diff --git a/changelog.d/10061-string-slice.md b/changelog.d/10061-string-slice.md new file mode 100644 index 0000000000..e880d8df55 --- /dev/null +++ b/changelog.d/10061-string-slice.md @@ -0,0 +1,9 @@ +Fix `String.prototype.slice`, `substring`, and `substr` at UTF-16 boundaries +inside astral characters, preserving either surrogate half and pre-existing +lone surrogates with consistent length and WTF-8 metadata. + +Avoid repeated full-suffix copying in eligible native parse loops by keeping +non-escaping suffix locals as rooted sources with scalar UTF-16/byte cursors. +Escaping strings retain their flat storage and independent lifetime. Includes +Unicode boundary, aliasing, moving-GC, and unchanged workload benchmark coverage +for #10061. diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 1facefbe65..616317cef8 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -1266,6 +1266,8 @@ pub(super) fn compile_closure( .non_escaping_array_length_only_indices() .clone(), fusible_uppercase_locals: native_facts.fusible_uppercase_locals().clone(), + suffix_cursor_locals: native_facts.suffix_cursor_locals().clone(), + suffix_cursors: std::collections::HashMap::new(), non_escaping_object_literals: native_facts.non_escaping_object_literals().clone(), non_escaping_object_literal_used_fields: native_facts .non_escaping_object_literal_used_fields() diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 5d2f5cc498..ed0ef5eccf 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -1040,6 +1040,8 @@ pub(super) fn compile_module_entry( .non_escaping_array_length_only_indices() .clone(), fusible_uppercase_locals: main_native_facts.fusible_uppercase_locals().clone(), + suffix_cursor_locals: main_native_facts.suffix_cursor_locals().clone(), + suffix_cursors: std::collections::HashMap::new(), non_escaping_object_literals: main_native_facts.non_escaping_object_literals().clone(), non_escaping_object_literal_used_fields: main_native_facts .non_escaping_object_literal_used_fields() @@ -1831,6 +1833,8 @@ pub(super) fn compile_module_entry( .non_escaping_array_length_only_indices() .clone(), fusible_uppercase_locals: init_native_facts.fusible_uppercase_locals().clone(), + suffix_cursor_locals: init_native_facts.suffix_cursor_locals().clone(), + suffix_cursors: std::collections::HashMap::new(), non_escaping_object_literals: init_native_facts.non_escaping_object_literals().clone(), non_escaping_object_literal_used_fields: init_native_facts .non_escaping_object_literal_used_fields() diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 0289c4b085..4fda54f02e 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -1210,6 +1210,8 @@ pub(super) fn compile_function( .non_escaping_array_length_only_indices() .clone(), fusible_uppercase_locals: native_facts.fusible_uppercase_locals().clone(), + suffix_cursor_locals: native_facts.suffix_cursor_locals().clone(), + suffix_cursors: std::collections::HashMap::new(), non_escaping_object_literals: native_facts.non_escaping_object_literals().clone(), non_escaping_object_literal_used_fields: native_facts .non_escaping_object_literal_used_fields() diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index c562c1de88..c38453e1f3 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -648,6 +648,8 @@ pub(super) fn compile_method( .non_escaping_array_length_only_indices() .clone(), fusible_uppercase_locals: native_facts.fusible_uppercase_locals().clone(), + suffix_cursor_locals: native_facts.suffix_cursor_locals().clone(), + suffix_cursors: std::collections::HashMap::new(), non_escaping_object_literals: native_facts.non_escaping_object_literals().clone(), non_escaping_object_literal_used_fields: native_facts .non_escaping_object_literal_used_fields() @@ -1793,6 +1795,8 @@ pub(super) fn compile_static_method( .non_escaping_array_length_only_indices() .clone(), fusible_uppercase_locals: native_facts.fusible_uppercase_locals().clone(), + suffix_cursor_locals: native_facts.suffix_cursor_locals().clone(), + suffix_cursors: std::collections::HashMap::new(), non_escaping_object_literals: native_facts.non_escaping_object_literals().clone(), non_escaping_object_literal_used_fields: native_facts .non_escaping_object_literal_used_fields() diff --git a/crates/perry-codegen/src/collectors/hir_facts.rs b/crates/perry-codegen/src/collectors/hir_facts.rs index 5a65978bb1..48b4d6b2b0 100644 --- a/crates/perry-codegen/src/collectors/hir_facts.rs +++ b/crates/perry-codegen/src/collectors/hir_facts.rs @@ -165,6 +165,7 @@ pub(crate) struct EscapeFacts { pub non_escaping_array_used_indices: HashMap>, pub non_escaping_array_length_only_indices: HashMap>, pub fusible_uppercase_locals: HashSet, + pub suffix_cursor_locals: HashSet, pub non_escaping_object_literals: HashMap>, pub non_escaping_object_literal_used_fields: HashMap>, /// #9843, the fourth member of this family: `for (let {segment: O} of @@ -388,6 +389,10 @@ impl TypeFacts { &self.escape.non_escaping_array_length_only_indices } + pub(crate) fn suffix_cursor_locals(&self) -> &HashSet { + &self.escape.suffix_cursor_locals + } + pub(crate) fn fusible_uppercase_locals(&self) -> &HashSet { &self.escape.fusible_uppercase_locals } @@ -672,6 +677,7 @@ pub(crate) fn collect_type_facts( stmts, &non_escaping_arrays, ); + let suffix_cursor_locals = super::suffix_strings::collect(stmts, boxed_vars, module_globals); let fusible_uppercase_locals = super::uppercase_strings::collect_fusible_uppercase_locals( stmts, &non_escaping_arrays, @@ -786,6 +792,7 @@ pub(crate) fn collect_type_facts( non_escaping_array_used_indices, non_escaping_array_length_only_indices, fusible_uppercase_locals, + suffix_cursor_locals, non_escaping_object_literals, non_escaping_object_literal_used_fields, segment_for_of_sites, diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index f576335f32..f633c0df3c 100644 --- a/crates/perry-codegen/src/collectors/mod.rs +++ b/crates/perry-codegen/src/collectors/mod.rs @@ -56,6 +56,7 @@ pub mod segview; mod segview_tests; mod shadow_slots; pub(crate) mod spec_abi_sites; +pub(crate) mod suffix_strings; mod this_as_value; mod uppercase_strings; diff --git a/crates/perry-codegen/src/collectors/suffix_strings.rs b/crates/perry-codegen/src/collectors/suffix_strings.rs new file mode 100644 index 0000000000..99ea593320 --- /dev/null +++ b/crates/perry-codegen/src/collectors/suffix_strings.rs @@ -0,0 +1,211 @@ +//! Prove that a local suffix is observed only through length/character reads. +//! Remove admitted uses in a temporary HIR copy, then use the exhaustive HIR +//! reference collector to reject every remaining read, write, and capture. + +use perry_hir::{Expr, Stmt}; +use std::collections::{HashMap, HashSet}; + +#[cfg(test)] +mod tests; + +pub(crate) fn literal_index(expr: &Expr) -> Option { + match expr { + Expr::Integer(n) => i32::try_from(*n).ok(), + Expr::Number(n) + if n.is_finite() + && n.fract() == 0.0 + && *n >= i32::MIN as f64 + && *n <= i32::MAX as f64 => + { + Some(*n as i32) + } + _ => None, + } +} + +pub(crate) fn method(expr: &Expr, name: &str) -> Option<(u32, i32)> { + let Expr::Call { callee, args, .. } = expr else { + return None; + }; + let Expr::PropertyGet { + object, property, .. + } = callee.as_ref() + else { + return None; + }; + let Expr::LocalGet(id) = object.as_ref() else { + return None; + }; + if property != name { + return None; + } + let index = match args.as_slice() { + [] => 0, + [arg] => literal_index(arg)?, + _ => return None, + }; + Some((*id, index)) +} + +pub(crate) fn collect( + stmts: &[Stmt], + boxed: &HashSet, + globals: &HashMap, +) -> HashSet { + // Top-level declarations dominate the admitted loops. Nested declarations + // remain materialized; in particular, no cursor crosses a closure region. + let mut candidates: HashSet = stmts + .iter() + .filter_map(|stmt| match stmt { + Stmt::Let { + id, + init: Some(_), + mutable: true, + .. + } if !boxed.contains(id) && !globals.contains_key(id) => Some(*id), + _ => None, + }) + .collect(); + if candidates.is_empty() || !stmts.iter().any(|s| has_update(s, &candidates)) { + return HashSet::new(); + } + let mut masked = stmts.to_vec(); + let mut updates = HashSet::new(); + for stmt in &mut masked { + mask_stmt(stmt, &candidates, &mut updates); + } + let mut refs = Vec::new(); + let mut visited = HashSet::new(); + for stmt in &masked { + perry_hir::analysis::collect_local_refs_stmt(stmt, &mut refs, &mut visited); + } + for id in refs { + candidates.remove(&id); + } + candidates.retain(|id| updates.contains(id)); + candidates +} + +fn has_update(stmt: &Stmt, candidates: &HashSet) -> bool { + let update = |expr: &Expr| match expr { + Expr::LocalSet(id, value) => { + candidates.contains(id) + && method(value, "slice") + .is_some_and(|(receiver, count)| receiver == *id && count >= 0) + } + _ => false, + }; + match stmt { + Stmt::Expr(expr) => update(expr), + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => { + body.iter().any(|s| has_update(s, candidates)) + } + Stmt::If { + then_branch, + else_branch, + .. + } => { + then_branch.iter().any(|s| has_update(s, candidates)) + || else_branch + .as_ref() + .is_some_and(|branch| branch.iter().any(|s| has_update(s, candidates))) + } + Stmt::For { + init, + update: step, + body, + .. + } => { + init.as_ref().is_some_and(|s| has_update(s, candidates)) + || step.as_ref().is_some_and(update) + || body.iter().any(|s| has_update(s, candidates)) + } + _ => false, + } +} + +fn mask_expr(expr: &mut Expr, allowed: &HashSet, updates: &mut HashSet, discarded: bool) { + if discarded { + if let Expr::LocalSet(id, value) = expr { + if let Some((receiver, count)) = method(value, "slice") { + if receiver == *id && count >= 0 && allowed.contains(id) { + updates.insert(*id); + *expr = Expr::Undefined; + return; + } + } + } + } + let admitted = match expr { + Expr::PropertyGet { + object, property, .. + } if property == "length" => { + matches!(object.as_ref(), Expr::LocalGet(id) if allowed.contains(id)) + } + _ => method(expr, "charCodeAt").is_some_and(|(id, _)| allowed.contains(&id)), + }; + if admitted { + *expr = Expr::Undefined; + return; + } + // Captures, closure defaults, and bodies must remain visible to the + // reference collector, even if their reads look locally fusible. + if matches!(expr, Expr::Closure { .. }) { + return; + } + perry_hir::walker::walk_expr_children_mut(expr, &mut |child| { + mask_expr(child, allowed, updates, false) + }); +} + +fn mask_stmt(stmt: &mut Stmt, allowed: &HashSet, updates: &mut HashSet) { + match stmt { + Stmt::Let { init: Some(e), .. } | Stmt::Return(Some(e)) | Stmt::Throw(e) => { + mask_expr(e, allowed, updates, false) + } + Stmt::Expr(e) => mask_expr(e, allowed, updates, true), + Stmt::If { + condition, + then_branch, + else_branch, + } => { + mask_expr(condition, allowed, updates, false); + for s in then_branch { + mask_stmt(s, allowed, updates); + } + if let Some(branch) = else_branch { + for s in branch { + mask_stmt(s, allowed, updates); + } + } + } + Stmt::While { condition, body } | Stmt::DoWhile { condition, body } => { + mask_expr(condition, allowed, updates, false); + for s in body { + mask_stmt(s, allowed, updates); + } + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(s) = init { + mask_stmt(s, allowed, updates); + } + if let Some(e) = condition { + mask_expr(e, allowed, updates, false); + } + if let Some(e) = update { + mask_expr(e, allowed, updates, true); + } + for s in body { + mask_stmt(s, allowed, updates); + } + } + // Unhandled control-flow shapes remain intact and conservatively + // reject any referenced candidate through the exhaustive collector. + _ => {} + } +} diff --git a/crates/perry-codegen/src/collectors/suffix_strings/tests.rs b/crates/perry-codegen/src/collectors/suffix_strings/tests.rs new file mode 100644 index 0000000000..824b82dffd --- /dev/null +++ b/crates/perry-codegen/src/collectors/suffix_strings/tests.rs @@ -0,0 +1,97 @@ +use super::*; +use perry_hir::types::Type; + +fn property(name: &str) -> Expr { + Expr::PropertyGet { + object: Box::new(Expr::LocalGet(1)), + property: name.into(), + byte_offset: 0, + } +} +fn call(name: &str, args: Vec) -> Expr { + Expr::Call { + callee: Box::new(property(name)), + args, + type_args: vec![], + byte_offset: 0, + } +} +fn assign(args: Vec) -> Expr { + Expr::LocalSet(1, Box::new(call("slice", args))) +} +fn body() -> Vec { + vec![ + Stmt::Let { + id: 1, + name: "s".into(), + ty: Type::String, + mutable: true, + init: Some(Expr::String("πŸ˜€abc".into())), + }, + Stmt::While { + condition: property("length"), + body: vec![ + Stmt::Expr(call("charCodeAt", vec![Expr::Integer(0)])), + Stmt::Expr(assign(vec![Expr::Integer(1)])), + ], + }, + ] +} +fn selected(stmts: &[Stmt]) -> bool { + collect(stmts, &HashSet::new(), &HashMap::new()).contains(&1) +} + +#[test] +fn accepts_scalar_consumption_with_utf16_strides() { + assert!(selected(&body())); + for count in [0, 2, 5, i32::MAX] { + let mut stmts = body(); + stmts.push(Stmt::Expr(assign(vec![Expr::Integer(count as i64)]))); + stmts.push(Stmt::Return(Some(property("length")))); + assert!(selected(&stmts)); + } +} + +#[test] +fn leaves_unrelated_and_immutable_locals_alone() { + assert!(!selected(&body()[..1])); + let mut stmts = body(); + let Stmt::Let { mutable, .. } = &mut stmts[0] else { + unreachable!(); + }; + *mutable = false; + assert!(!selected(&stmts)); +} + +#[test] +fn rejects_escapes_aliases_captures_and_unsupported_updates() { + let cases = [ + Stmt::Return(Some(Expr::LocalGet(1))), + Stmt::Let { + id: 2, + name: "alias".into(), + ty: Type::String, + mutable: false, + init: Some(Expr::LocalGet(1)), + }, + Stmt::Return(Some(assign(vec![Expr::Integer(1)]))), + Stmt::Expr(assign(vec![Expr::Integer(-1)])), + Stmt::Expr(assign(vec![Expr::Integer(1), Expr::Integer(2)])), + Stmt::Expr(assign(vec![Expr::LocalGet(3)])), + Stmt::Expr(call("charCodeAt", vec![Expr::LocalGet(3)])), + Stmt::Expr(call("toString", vec![])), + Stmt::Expr(Expr::LocalSet(1, Box::new(Expr::String("changed".into())))), + ]; + for extra in cases { + let mut stmts = body(); + stmts.push(extra.clone()); + assert!(!selected(&stmts), "must reject {extra:?}"); + } + assert!(collect(&body(), &HashSet::from([1]), &HashMap::new()).is_empty()); + assert!(collect( + &body(), + &HashSet::new(), + &HashMap::from([(1, "global".into())]) + ) + .is_empty()); +} diff --git a/crates/perry-codegen/src/expr/dispatch.rs b/crates/perry-codegen/src/expr/dispatch.rs index 89ada2b9ed..7fd8fd61c4 100644 --- a/crates/perry-codegen/src/expr/dispatch.rs +++ b/crates/perry-codegen/src/expr/dispatch.rs @@ -28,6 +28,9 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // one. Handlers that care receive it as an argument, because they consult // it after lowering their operands β€” by which point the field is gone. let value_discarded = std::mem::take(&mut ctx.discard_this_expr); + if let Some(value) = super::suffix_cursor::try_lower(ctx, expr)? { + return Ok(value); + } if let Some(lowered) = lower_expr_value(ctx, expr)? { if ctx.discard_expr_value { return Ok(materialize_js_value_without_record(ctx, lowered)); diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 60b33c7168..bcc1eb6f32 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -1458,6 +1458,10 @@ pub(crate) struct FnCtx<'a> { pub non_escaping_array_length_only_indices: std::collections::HashMap>, pub fusible_uppercase_locals: std::collections::HashSet, + /// Locals proven to have only scalar suffix consumers. + pub suffix_cursor_locals: std::collections::HashSet, + /// Stack storage holds byte/UTF-16 offsets only, never a GC pointer. + pub suffix_cursors: std::collections::HashMap, /// Non-escaping object literals identified by escape analysis. Maps /// local_id β†’ field names (declaration order, deduplicated). Used by @@ -2785,6 +2789,7 @@ pub(crate) mod masked_window; mod null_default_numeric_add_tests; mod string_length; pub(crate) mod string_window; +pub(crate) mod suffix_cursor; mod ptr_numarray_access; mod ta_param_f64_read; diff --git a/crates/perry-codegen/src/expr/suffix_cursor.rs b/crates/perry-codegen/src/expr/suffix_cursor.rs new file mode 100644 index 0000000000..86e5aeb03a --- /dev/null +++ b/crates/perry-codegen/src/expr/suffix_cursor.rs @@ -0,0 +1,97 @@ +//! Virtual suffix lowering. Only offsets are stored outside ordinary locals; +//! the existing source-local root remains responsible for GC relocation. + +use crate::expr::{lower_expr, FnCtx}; +use crate::types::{DOUBLE, I1, I32, I64}; +use anyhow::Result; +use perry_hir::Expr; + +pub(crate) fn initialize(ctx: &mut FnCtx<'_>, id: u32) { + let slot = ctx.func.alloca_entry("[3 x i32]"); + ctx.block().store("[3 x i32]", "zeroinitializer", &slot); + ctx.suffix_cursors.insert(id, slot); +} + +pub(crate) fn try_lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result> { + let (id, operation, index) = match expr { + Expr::PropertyGet { + object, property, .. + } if property == "length" => { + let Expr::LocalGet(id) = object.as_ref() else { + return Ok(None); + }; + (*id, "length", 0) + } + Expr::LocalSet(id, value) => { + let Some((receiver, count)) = crate::collectors::suffix_strings::method(value, "slice") + else { + return Ok(None); + }; + if *id != receiver || count < 0 { + return Ok(None); + } + (*id, "advance", count) + } + _ => { + let Some((id, index)) = crate::collectors::suffix_strings::method(expr, "charCodeAt") + else { + return Ok(None); + }; + (id, "char_code_at", index) + } + }; + let Some(cursor_slot) = ctx.suffix_cursors.get(&id).cloned() else { + return Ok(None); + }; + let source = lower_expr(ctx, &Expr::LocalGet(id))?; + let bits = ctx.block().bitcast_double_to_i64(&source); + let tag = ctx.block().lshr(I64, &bits, "48"); + let heap = ctx + .block() + .icmp_eq(I64, &tag, crate::nanbox::STRING_TAG_TOP16_I64); + let short = ctx + .block() + .icmp_eq(I64, &tag, crate::nanbox::SHORT_STRING_TAG_TOP16_I64); + let is_string = ctx.block().or(I1, &heap, &short); + let fast = ctx.new_block("suffix.string"); + let slow = ctx.new_block("suffix.other"); + let merge = ctx.new_block("suffix.merge"); + let fast_label = ctx.block_label(fast); + let slow_label = ctx.block_label(slow); + let merge_label = ctx.block_label(merge); + ctx.block().cond_br(&is_string, &fast_label, &slow_label); + ctx.current_block = fast; + let cursor = ctx.block().ptrtoint(&cursor_slot, I64); + let index = index.to_string(); + let fast_value = if operation == "advance" { + ctx.block().call_void( + "js_string_suffix_advance", + &[(DOUBLE, &source), (I64, &cursor), (I32, &index)], + ); + source + } else { + let mut args = vec![(DOUBLE, source.as_str()), (I64, cursor.as_str())]; + if operation == "char_code_at" { + args.push((I32, &index)); + } + ctx.block() + .call(DOUBLE, &format!("js_string_suffix_{operation}"), &args) + }; + let fast_pred = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = slow; + // Types are not runtime validation. Preserve the existing method/property + // behavior if a string-annotated local actually contains another value. + ctx.suffix_cursors.remove(&id); + let fallback = lower_expr(ctx, expr); + ctx.suffix_cursors.insert(id, cursor_slot); + let fallback = fallback?; + let slow_pred = ctx.block().label.clone(); + ctx.block().br(&merge_label); + ctx.current_block = merge; + Ok(Some(ctx.block().phi( + DOUBLE, + &[(&fast_value, &fast_pred), (&fallback, &slow_pred)], + ))) +} diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 2e99cd4db1..e221ce098f 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -10,6 +10,13 @@ use super::*; /// and `and` with `POINTER_MASK` (0x0000_FFFF_FFFF_FFFF), then re-boxes the /// result with `js_nanbox_string`. pub fn declare_phase_b_strings(module: &mut LlModule) { + module.declare_function("js_string_suffix_length", DOUBLE, &[DOUBLE, I64]); + module.declare_function( + "js_string_suffix_advance", + crate::types::VOID, + &[DOUBLE, I64, I32], + ); + module.declare_function("js_string_suffix_char_code_at", DOUBLE, &[DOUBLE, I64, I32]); module.declare_function("js_string_concat", I64, &[I64, I64]); // SSO-aware concat: NaN-boxed f64 in, NaN-boxed f64 out. Avoids // the `js_get_string_pointer_unified`-driven SSO materialization diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index 6550f09bab..1e4729b45b 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -396,7 +396,13 @@ pub(crate) fn lower_stmt(ctx: &mut FnCtx<'_>, stmt: &Stmt) -> Result<()> { ty, mutable, .. - } => lower_let(ctx, *id, name, init.as_ref(), ty, *mutable), + } => { + lower_let(ctx, *id, name, init.as_ref(), ty, *mutable)?; + if ctx.suffix_cursor_locals.contains(id) { + crate::expr::suffix_cursor::initialize(ctx, *id); + } + Ok(()) + } Stmt::If { condition, diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/string_slice.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/string_slice.rs index fd182ba49d..dc668c301d 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/string_slice.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/string_slice.rs @@ -1,5 +1,49 @@ use super::*; +#[test] +fn suffix_cursor_offsets_survive_source_evacuation_and_split_slice_owns_its_bytes() { + use crate::string::suffix_cursor::*; + let _guard = CopyingNurseryTestGuard::new(0); + let _trigger = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _mode = + crate::arena::ProtectionModeGuard::set(crate::arena::FromSpaceProtection::PoisonOnly); + register_runtime_handle_root_scanner_for_tests(); + let scope = RuntimeHandleScope::new(); + let bytes = "Γ€δΈ­πŸ˜€Γ–".repeat(100); + let source = crate::string::js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); + assert!(crate::arena::pointer_in_nursery(source as usize)); + let root = scope.root_string_ptr(source); + let before = source as usize; + let boxed = |s| f64::from_bits(crate::value::JSValue::string_ptr(s).bits()); + let mut cursor = SuffixCursor::default(); + unsafe { + js_string_suffix_advance(boxed(source), &mut cursor, 3); + } + let kept = crate::string::js_string_slice(source, 3, 5); + let kept_root = scope.root_string_ptr(kept); + gc_collect_minor(); + let source = root.get_raw_mut_ptr::(); + assert_ne!( + source as usize, before, + "the test must actually move the source" + ); + unsafe { + assert_eq!(js_string_suffix_length(boxed(source), &cursor), 497.0); + assert_eq!( + js_string_suffix_char_code_at(boxed(source), &cursor, 0), + 56832.0 + ); + js_string_suffix_advance(boxed(source), &mut cursor, 1); + assert_eq!( + js_string_suffix_char_code_at(boxed(source), &cursor, 0), + 214.0 + ); + } + let kept = kept_root.get_raw_const_ptr::(); + assert_eq!(crate::string::js_string_char_code_at(kept, 0), 56832.0); + assert_eq!(crate::string::js_string_char_code_at(kept, 1), 214.0); +} + /// #5062: `String.prototype.slice` copies the selected range out of the source /// string AFTER allocating the destination, via a raw pointer derived from the /// source (`string_data(s) + offset`). If that destination allocation trips a diff --git a/crates/perry-runtime/src/string/mod.rs b/crates/perry-runtime/src/string/mod.rs index a284456320..d5f8641c03 100644 --- a/crates/perry-runtime/src/string/mod.rs +++ b/crates/perry-runtime/src/string/mod.rs @@ -118,11 +118,15 @@ mod locale; mod pad; mod raw; mod slice_ops; +mod slice_range; mod split; +pub(crate) mod suffix_cursor; mod utf16_count; #[cfg(feature = "regex-engine")] pub(crate) use split::{spec_fancy_regex_split, spec_regex_split}; +#[cfg(test)] +mod slice_tests; #[cfg(test)] mod tests; diff --git a/crates/perry-runtime/src/string/slice_ops.rs b/crates/perry-runtime/src/string/slice_ops.rs index 3463f27127..5b46225808 100644 --- a/crates/perry-runtime/src/string/slice_ops.rs +++ b/crates/perry-runtime/src/string/slice_ops.rs @@ -2,7 +2,7 @@ use super::*; -/// Get a slice of a string (byte-based for now) +/// Get a slice of a string in UTF-16 code units /// Returns a new string from start to end (exclusive). /// start/end are in UTF-16 code unit indices (JS semantics). #[no_mangle] @@ -33,24 +33,7 @@ pub extern "C" fn js_string_slice( return js_string_from_bytes(ptr::null(), 0); } - // ASCII fast path: byte offsets == UTF-16 offsets, skip utf16_len scan. - // Copy GC-safely: the destination allocation can move/sweep `s` (#5062). - if is_ascii_string(s) { - let slice_len = (end - start) as u32; - return string_copy_range(s, start as usize, slice_len, slice_len, 0); - } - - // Convert UTF-16 offsets to byte offsets - let str_data = string_as_str(s); - let byte_start = utf16_offset_to_byte_offset(str_data, start as usize); - let byte_end = utf16_offset_to_byte_offset(str_data, end as usize); - string_copy_range( - s, - byte_start, - (byte_end - byte_start) as u32, - (end - start) as u32, - 0, - ) + super::slice_range::copy_utf16_range(s, start as u32, end as u32) } /// Get a substring (similar to slice but different behavior) @@ -82,23 +65,7 @@ pub extern "C" fn js_string_substring( return js_string_from_bytes(ptr::null(), 0); } - // ASCII fast path: skip utf16_len scan in allocator. - // Copy GC-safely: the destination allocation can move/sweep `s` (#5062). - if is_ascii_string(s) { - let slice_len = (end - start) as u32; - return string_copy_range(s, start as usize, slice_len, slice_len, 0); - } - - let str_data = string_as_str(s); - let byte_start = utf16_offset_to_byte_offset(str_data, start as usize); - let byte_end = utf16_offset_to_byte_offset(str_data, end as usize); - string_copy_range( - s, - byte_start, - (byte_end - byte_start) as u32, - (end - start) as u32, - 0, - ) + super::slice_range::copy_utf16_range(s, start as u32, end as u32) } /// Legacy `String.prototype.substr(start, length)` (ECMA-262 Annex B.2.3.1). @@ -159,23 +126,7 @@ pub extern "C" fn js_string_substr( let start = start as i32; let end = end as i32; - // ASCII fast path: byte offsets == UTF-16 offsets. - // Copy GC-safely: the destination allocation can move/sweep `s` (#5062). - if is_ascii_string(s) { - let slice_len = (end - start) as u32; - return string_copy_range(s, start as usize, slice_len, slice_len, 0); - } - - let str_data = string_as_str(s); - let byte_start = utf16_offset_to_byte_offset(str_data, start as usize); - let byte_end = utf16_offset_to_byte_offset(str_data, end as usize); - string_copy_range( - s, - byte_start, - (byte_end - byte_start) as u32, - (end - start) as u32, - 0, - ) + super::slice_range::copy_utf16_range(s, start as u32, end as u32) } // `#[used]` keepalive: `js_string_substr` is reached only from generated `.o`, diff --git a/crates/perry-runtime/src/string/slice_range.rs b/crates/perry-runtime/src/string/slice_range.rs new file mode 100644 index 0000000000..e4fbd12577 --- /dev/null +++ b/crates/perry-runtime/src/string/slice_range.rs @@ -0,0 +1,78 @@ +//! UTF-16 boundaries over the runtime's UTF-8/WTF-8 payloads. + +use super::*; + +/// A boundary can lie between the two code units of one four-byte scalar. +/// In that case `byte` still names the scalar's lead byte. +#[derive(Clone, Copy, Default)] +pub(super) struct Boundary { + pub byte: usize, + pub low: bool, +} + +pub(super) fn advance(bytes: &[u8], mut at: Boundary, mut units: usize) -> Boundary { + while units > 0 && at.byte < bytes.len() { + let (width, count, _) = wtf8_step(bytes, at.byte); + let count = count.saturating_sub(usize::from(at.low)); + if units < count { + at.low = true; + break; + } + units -= count; + at.byte = (at.byte + width).min(bytes.len()); + at.low = false; + } + at +} + +/// Copy a normalized nonempty UTF-16 range, preserving split surrogate halves. +/// Complete scalar ranges use the rooted copy helper. Split boundaries are +/// staged in Rust-owned bytes before the destination allocation can collect. +pub(super) fn copy_utf16_range(s: *const StringHeader, start: u32, end: u32) -> *mut StringHeader { + if is_ascii_string(s) { + return string_copy_range(s, start as usize, end - start, end - start, 0); + } + let bytes = unsafe { slice::from_raw_parts(string_data(s), (*s).byte_len as usize) }; + let first = advance(bytes, Boundary::default(), start as usize); + // A suffix's end is already known: do not scan the entire remaining string. + let last = if end == unsafe { (*s).utf16_len } { + Boundary { + byte: bytes.len(), + low: false, + } + } else { + advance(bytes, first, (end - start) as usize) + }; + if !first.low && !last.low { + let part = &bytes[first.byte..last.byte]; + let flags = if unsafe { (*s).flags } & STRING_FLAG_HAS_LONE_SURROGATES != 0 + && bytes_have_lone_surrogate(part) + { + STRING_FLAG_HAS_LONE_SURROGATES + } else { + 0 + }; + return string_copy_range(s, first.byte, part.len() as u32, end - start, flags); + } + + let mut out = Vec::with_capacity(last.byte - first.byte + 6); + let mut byte_start = first.byte; + if first.low { + let (width, _, cp) = wtf8_step(bytes, first.byte); + let low = 0xDC00 + (cp.wrapping_sub(0x10000) & 0x3FF) as u16; + char_ops::push_code_unit_wtf8(&mut out, low); + byte_start = (byte_start + width).min(bytes.len()); + } + out.extend_from_slice(&bytes[byte_start..last.byte]); + if last.low { + let (_, _, cp) = wtf8_step(bytes, last.byte); + let high = 0xD800 + ((cp.wrapping_sub(0x10000) >> 10) & 0x3FF) as u16; + char_ops::push_code_unit_wtf8(&mut out, high); + } + js_string_from_bytes_known_utf16( + out.as_ptr(), + out.len() as u32, + end - start, + STRING_FLAG_HAS_LONE_SURROGATES, + ) +} diff --git a/crates/perry-runtime/src/string/slice_tests.rs b/crates/perry-runtime/src/string/slice_tests.rs new file mode 100644 index 0000000000..24ec857593 --- /dev/null +++ b/crates/perry-runtime/src/string/slice_tests.rs @@ -0,0 +1,127 @@ +use super::suffix_cursor::*; +use super::*; +use crate::value::JSValue; + +fn units(s: *const StringHeader) -> Vec { + (0..unsafe { (*s).utf16_len }) + .map(|i| js_string_char_code_at(s, i as i32) as u16) + .collect() +} + +#[test] +fn slice_utf16_bounds_and_lone_surrogates() { + for bytes in [ + b"".as_slice(), + b"abc", + "Γ€δΈ­πŸ˜€Γ–".as_bytes(), + "πŸ˜€πŸ˜€".as_bytes(), + b"\xed\xa0\x80A\xed\xbf\xbf", + b"\xed\xa0\x80\xf0\x9f\x98\x80\xed\xbf\xbf", + ] { + let scope = crate::gc::RuntimeHandleScope::new(); + let source = js_string_from_wtf8_bytes(bytes.as_ptr(), bytes.len() as u32); + let root = scope.root_string_ptr(source); + let expected = units(source); + let len = expected.len() as i32; + for start in -len - 2..=len + 2 { + for end in -len - 2..=len + 2 { + let normalize = |n: i32| if n < 0 { (len + n).max(0) } else { n.min(len) }; + let a = normalize(start) as usize; + let b = normalize(end) as usize; + let result = js_string_slice(root.get_raw_const_ptr(), start, end); + assert_eq!( + units(result), + expected[a..b.max(a)], + "slice({start}, {end}) of {expected:?}" + ); + let payload = unsafe { + slice::from_raw_parts(string_data(result), (*result).byte_len as usize) + }; + assert_eq!( + unsafe { (*result).utf16_len }, + compute_utf16_len_wtf8(payload) + ); + assert_eq!( + unsafe { (*result).flags } & STRING_FLAG_HAS_LONE_SURROGATES != 0, + bytes_have_lone_surrogate(payload) + ); + let a = start.clamp(0, len) as usize; + let b = end.clamp(0, len) as usize; + let result = js_string_substring(root.get_raw_const_ptr(), start, end); + assert_eq!(units(result), expected[a.min(b)..a.max(b)]); + } + } + assert!( + units(js_string_slice( + root.get_raw_const_ptr(), + i32::MIN, + i32::MAX + )) == expected + ); + } +} + +#[test] +fn suffix_cursor_matches_code_units_and_clamps_reads_and_advances() { + for text in ["", "abc", "Γ€δΈ­πŸ˜€Γ–", "πŸ˜€πŸ˜€"] { + let source = js_string_from_str(text); + let boxed = f64::from_bits(JSValue::string_ptr(source).bits()); + let expected: Vec = text.encode_utf16().collect(); + for step in [0, 1, 2, 3, 20] { + let mut cursor = SuffixCursor::default(); + let mut consumed = 0; + for _ in 0..expected.len() + 2 { + unsafe { + assert_eq!( + js_string_suffix_length(boxed, &cursor), + (expected.len() - consumed) as f64 + ); + for index in -1..=expected.len() as i32 { + let got = js_string_suffix_char_code_at(boxed, &cursor, index); + if index < 0 || consumed + index as usize >= expected.len() { + assert!(got.is_nan()); + } else { + assert_eq!(got, expected[consumed + index as usize] as f64); + } + } + js_string_suffix_advance(boxed, &mut cursor, step); + } + consumed = (consumed + step as usize).min(expected.len()); + } + } + } + let short = f64::from_bits(JSValue::try_short_string(b"abc").unwrap().bits()); + let mut cursor = SuffixCursor::default(); + unsafe { + js_string_suffix_advance(short, &mut cursor, 1); + assert_eq!(js_string_suffix_length(short, &cursor), 2.0); + assert_eq!(js_string_suffix_char_code_at(short, &cursor, 0), 98.0); + } +} + +#[test] +fn suffix_cursor_skips_unpaired_continuation_bytes_like_char_code_at() { + let bytes = b"\x80A\x80\xf0\x9f\x98\x80\x80B"; + let source = js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); + let boxed = f64::from_bits(JSValue::string_ptr(source).bits()); + let expected = units(source); + assert_eq!(expected, [65, 55357, 56832, 66]); + let mut cursor = SuffixCursor::default(); + for consumed in 0..=expected.len() { + unsafe { + for index in 0..expected.len() - consumed { + assert_eq!( + js_string_suffix_char_code_at(boxed, &cursor, index as i32), + expected[consumed + index] as f64 + ); + } + assert!(js_string_suffix_char_code_at( + boxed, + &cursor, + (expected.len() - consumed) as i32 + ) + .is_nan()); + js_string_suffix_advance(boxed, &mut cursor, 1); + } + } +} diff --git a/crates/perry-runtime/src/string/suffix_cursor.rs b/crates/perry-runtime/src/string/suffix_cursor.rs new file mode 100644 index 0000000000..82ba1f02bc --- /dev/null +++ b/crates/perry-runtime/src/string/suffix_cursor.rs @@ -0,0 +1,116 @@ +//! Allocation-free operations on a compiler-proven, non-escaping suffix. +//! +//! The source remains a normal, rooted string. The stack cursor contains only +//! offsets, so moving GC cannot invalidate it. No substring or backing store +//! escapes this representation; ordinary string consumers still get flat data. + +use super::slice_range::{advance, Boundary}; +use super::*; + +/// Mirrors the code generator's zero-initialized `[3 x i32]` stack allocation. +#[repr(C)] +#[derive(Default)] +pub struct SuffixCursor { + byte: u32, + consumed: u32, + low: u32, +} + +fn length(source: f64) -> u32 { + let value = crate::value::JSValue::from_bits(source.to_bits()); + if value.is_short_string() { + value.short_string_len() as u32 + } else { + unsafe { (*value.as_string_ptr()).utf16_len } + } +} + +/// The compiler guards `source` as a string and supplies live stack storage. +#[no_mangle] +pub unsafe extern "C" fn js_string_suffix_length(source: f64, cursor: *const SuffixCursor) -> f64 { + length(source).saturating_sub((*cursor).consumed) as f64 +} + +/// Advance by a nonnegative, already-coerced UTF-16 count, clamped to the tail. +#[no_mangle] +pub unsafe extern "C" fn js_string_suffix_advance( + source: f64, + cursor: *mut SuffixCursor, + count: i32, +) { + let count = (count.max(0) as u32).min(length(source).saturating_sub((*cursor).consumed)); + let mut scratch = [0; crate::value::SHORT_STRING_MAX_LEN]; + let (data, len) = str_bytes_from_jsvalue(source, &mut scratch).unwrap(); + let bytes = if len == 0 { + &[] + } else { + slice::from_raw_parts(data, len as usize) + }; + let at = advance( + bytes, + Boundary { + byte: (*cursor).byte as usize, + low: (*cursor).low != 0, + }, + count as usize, + ); + (*cursor).byte = at.byte as u32; + (*cursor).low = u32::from(at.low); + (*cursor).consumed += count; +} + +/// Read a UTF-16 code unit relative to the current suffix without materializing it. +#[no_mangle] +pub unsafe extern "C" fn js_string_suffix_char_code_at( + source: f64, + cursor: *const SuffixCursor, + index: i32, +) -> f64 { + if index < 0 || index as u32 >= length(source).saturating_sub((*cursor).consumed) { + return f64::NAN; + } + let mut scratch = [0; crate::value::SHORT_STRING_MAX_LEN]; + let (data, len) = str_bytes_from_jsvalue(source, &mut scratch).unwrap(); + let bytes = slice::from_raw_parts(data, len as usize); + let mut at = advance( + bytes, + Boundary { + byte: (*cursor).byte as usize, + low: (*cursor).low != 0, + }, + index as usize, + ); + if at.byte >= bytes.len() { + return f64::NAN; + } + // Invalid FFI/binary payloads can contain stray continuation bytes, which + // the runtime's UTF-16 counter and charCodeAt skip rather than count. + while at.byte < bytes.len() && wtf8_step(bytes, at.byte).1 == 0 { + at.byte += 1; + } + if at.byte >= bytes.len() { + return f64::NAN; + } + let (_, units, cp) = wtf8_step(bytes, at.byte); + if units == 2 { + let v = cp.wrapping_sub(0x10000); + if at.low { + (0xDC00 + (v & 0x3FF)) as f64 + } else { + (0xD800 + ((v >> 10) & 0x3FF)) as f64 + } + } else { + cp as f64 + } +} + +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_LENGTH: unsafe extern "C" fn(f64, *const SuffixCursor) -> f64 = js_string_suffix_length; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_ADVANCE: unsafe extern "C" fn(f64, *mut SuffixCursor, i32) = js_string_suffix_advance; +#[cfg(feature = "keepalive-anchors")] +#[used] +static KEEP_CHAR_CODE: unsafe extern "C" fn(f64, *const SuffixCursor, i32) -> f64 = + js_string_suffix_char_code_at; diff --git a/test-files/test_gap_gc_string_suffix_cursor.ts b/test-files/test_gap_gc_string_suffix_cursor.ts new file mode 100644 index 0000000000..8a8a1c2de2 --- /dev/null +++ b/test-files/test_gap_gc_string_suffix_cursor.ts @@ -0,0 +1,28 @@ +// #10061: the source remains rooted after its original binding is overwritten. +// parity-env: PERRY_GC_SCHEDULE_SEED=10061 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_SCHEDULE_ALLOC_KB=0 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_PROTECT_FROMSPACE=1 +function parse(): number { + let source = "Γ€δΈ­πŸ˜€Γ–".repeat(20); + let s = source; + source = "gone"; + let hash = 0; + while (s.length) { + const garbage = [s.length, hash, { value: hash }]; + hash = (hash * 31 + s.charCodeAt(0) + garbage[0]) % 1000000007; + s = s.slice(1); + } + return hash; +} +for (let i = 0; i < 5; i++) console.log(parse()); + +// A retained substring owns its bytes after both the source and other suffixes die. +function keep(): string { + let source = "Γ€δΈ­πŸ˜€Γ–".repeat(30); + const kept = source.slice(3, 6); + source = "released"; + for (let i = 0; i < 50; i++) { + const garbage = ("noise" + i).repeat(100); + if (garbage.length < 100) throw new Error("invalid allocation"); + } + return kept; +} +console.log(JSON.stringify(keep())); diff --git a/test-files/test_gap_string_slice_utf16_suffix.ts b/test-files/test_gap_string_slice_utf16_suffix.ts new file mode 100644 index 0000000000..16b56cc4d5 --- /dev/null +++ b/test-files/test_gap_string_slice_utf16_suffix.ts @@ -0,0 +1,59 @@ +// #10061: materialized slices and scalar suffix consumers must agree with Node. +function inspect(input: string): string { + let s = input; + let out = ""; + for (let i = 0; i < 16 && s.length; i++) { + out += s.length + ":" + s.charCodeAt(0) + ","; + s = s.slice(1); + } + return out; +} +console.log(inspect("Γ€δΈ­πŸ˜€Γ–")); +console.log(inspect("\ud800AπŸ˜€\udfff")); +console.log(inspect("")); + +const input = "Γ€δΈ­πŸ˜€Γ–\ud800A\udfff"; +for (let start = -11; start <= 11; start++) { + for (let end = -11; end <= 11; end++) { + const part = input.slice(start, end); + console.log(start, end, part.length, JSON.stringify(part), part.isWellFormed()); + } +} +console.log("πŸ˜€".substring(0, 1).charCodeAt(0), "πŸ˜€".substr(1, 1).charCodeAt(0)); + +// Escapes and aliases require ordinary flat strings. +let current = "Γ€δΈ­πŸ˜€Γ–"; +const retained: string[] = []; +while (current.length) { + retained.push(current); + current = current.slice(1); +} +for (let i = 0; i < retained.length; i++) console.log(JSON.stringify(retained[i])); +let captured = "πŸ˜€Γ–"; +const read = () => captured; +captured = captured.slice(1); +console.log(JSON.stringify(read())); +let builder = ""; +builder += "πŸ˜€"; +let alias = builder; +builder += "X"; +console.log(inspect(alias), builder); + +// Different strides, out-of-range reads, and repeated slicing after exhaustion. +function stride(input: string): string { + let s = input; + let result = ""; + for (let i = 0; i < 8; i++) { + result += s.length + ":" + s.charCodeAt(0) + ":" + s.charCodeAt(1) + ","; + s = s.slice(2); + } + return result; +} +console.log(stride("Γ€δΈ­πŸ˜€Γ–πŸ˜€")); +// A string annotation must not erase a non-string receiver's own methods. +const custom: any = { + length: 3, + charCodeAt: (i: number) => 70 + i, + slice: (_: number) => "xy", +}; +console.log(stride(custom)); diff --git a/test-parity/gc_repsel_corpus.txt b/test-parity/gc_repsel_corpus.txt index 80310c49f9..1e14f29a38 100644 --- a/test-parity/gc_repsel_corpus.txt +++ b/test-parity/gc_repsel_corpus.txt @@ -859,3 +859,5 @@ test_gap_gc_coalesce_local_root # for-in output/receiver custody across Proxy callbacks (#4644) test_gap_gc_for_in_proxy_callback_roots + +test_gap_gc_string_suffix_cursor From 8d66646ffe4f7eb8f0b9d7b9f2573ee7af6d4bd8 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Fri, 11 Sep 2026 15:19:52 +0200 Subject: [PATCH 2/3] docs: record string suffix benchmark and GC validation --- benchmarks/string_slice/README.md | 73 +++++++ benchmarks/string_slice/fixed-artifacts.json | 8 + benchmarks/string_slice/fixed.json | 204 +++++++++++++++++++ benchmarks/string_slice/validation.json | 89 ++++++++ 4 files changed, 374 insertions(+) create mode 100644 benchmarks/string_slice/fixed-artifacts.json create mode 100644 benchmarks/string_slice/fixed.json create mode 100644 benchmarks/string_slice/validation.json diff --git a/benchmarks/string_slice/README.md b/benchmarks/string_slice/README.md index 518abff5ab..85f7fd7937 100644 --- a/benchmarks/string_slice/README.md +++ b/benchmarks/string_slice/README.md @@ -77,3 +77,76 @@ Node: 5:228,4:20013,3:55357,2:56832,1:214, The baseline ASCII exponent is 2.013 over completed sizes 100–10,000; 100,000 times out. Unicode fails checksum stability at 100 and mismatches at 1,000 and 10,000, so its baseline speed is not classified. + +## Final measurements + +CPU: AMD Ryzen 5 7640HS, 12 logical processors. LLVM 22.1.8. +Compiler/archive source revision: `0c5348ce5694654d8aa6e4477900ebc9fffe67de`. +`fixed-artifacts.json` records the matching compiler and archive SHA-256 hashes. +All ten original workload/size pairs complete with stable checksums matching Node. + +### ASCII + +| n | Base Perry ms / status | Fixed Perry ms | Fixed Node ms | Perry / Node | Checksum | +|---:|---:|---:|---:|---:|---:| +| 100 | 0.015718 | 0.005188 | 0.006745 | 0.77x | 464151292 | +| 1,000 | 1.390067 | 0.052812 | 0.080744 | 0.65x | 710929850 | +| 10,000 | 166.934100 | 0.526479 | 0.804260 | 0.65x | 535454277 | +| 100,000 | TIMEOUT | 5.251350 | 7.428233 | 0.71x | 35382078 | +| 1,000,000 | NOT RUN | 52.088600 | 71.593200 | 0.73x | 153135489 | + +Fixed log-log slopes over all five sizes: Perry **1.000**, Node **1.002**. + +### UNICODE + +| n | Base Perry ms / status | Fixed Perry ms | Fixed Node ms | Perry / Node | Checksum | +|---:|---:|---:|---:|---:|---:| +| 100 | ERROR | 0.006948 | 0.010043 | 0.69x | 319467163 | +| 1,000 | 14.891150 (wrong checksum) | 0.070113 | 0.100875 | 0.70x | 431622199 | +| 10,000 | 1736.238200 (wrong checksum) | 0.699286 | 1.004910 | 0.70x | 36132863 | +| 100,000 | TIMEOUT | 7.232333 | 8.955500 | 0.81x | 49951631 | +| 1,000,000 | NOT RUN | 69.962600 | 89.631600 | 0.78x | 481167302 | + +Fixed log-log slopes over all five sizes: Perry **1.002**, Node **0.985**. + +## Validation and host limitations + +- Final native reduction exactly matches Node: `5:228,4:20013,3:55357,2:56832,1:214,`. +- Final compiled boundary/aliasing fixture matches Node, including empty and + negative bounds, both surrogate halves, lone surrogates, retained aliases, + captured locals, stride-two reads, and a non-string runtime receiver. +- Final forced-GC fixture matches Node with 1,061 copying minors, + 168 moved objects and 1,055 loop polls; evacuation verification + and from-space protection are enabled. Two runtime slice GC tests pass, + including an assertion that the source address actually changes. +- Runtime suite: 3,439 passed, one failed, four ignored (`--test-threads=1`). + All three new slice/cursor unit tests pass. The failing unchanged + `emergency_full_trace_is_excluded_from_ordinary_pause_stats` assertion expects + allocator trimming to be unsupported on this Windows host; it fails in + isolation too. +- Compiler unit suite (candidate build): 1,460 passed, three failed, one ignored. All three new + eligibility-analysis tests pass. The unchanged failures are a frameless-entry + assembly assertion and two native-emission byte-equality assertions; each + also fails in isolation on Windows. See `validation.json` for exact names. +- The broader 68-case string sweep on the initial candidate build has 47 passes, + one parity mismatch, 19 compile failures, and one skip. Every compile failure + reports the existing Windows RS4GC/WinEH restriction (#7354). The mismatch is + `test_gap_tolocalestring_locale_options_9414`: Node's default locale is German + on this host, while Perry formats default-locale rows as English. All three + focused native fixtures were recompiled and rechecked on the final build. +- Test registration, Node-version consistency, GC root-holder/store/address + inventories, local-binding proof audit, architecture checks, and public + baseline harness tests pass. The Rust file-size gate passes. Recursive + `rustfmt --check --edition 2021` on both changed crate roots passes; the + workspace-wide `cargo fmt` invocation exceeds Windows' command-line limit. +- The quick pre-tag gate's published-benchmark freshness check remains red. + Its fingerprinted inputs are identical to pristine base (recorded in + `public-baseline-check.json`); this PR does not regenerate that unrelated + published artifact. These host/gate limitations are reported, not counted + as passing checks. Linux/macOS and full workspace checks were not run locally. + +Re-run the affected suites with `cargo test --release --locked --lib -p +perry-runtime -- --test-threads=1` and `cargo test --release --locked --lib -p +perry-codegen`. Use the same build environment as above. The corpus sweep was +`bash run_parity_tests.sh --filter string` with `PERRY_SKIP_BUILD=1`, `PERRY_BIN` +and `PERRY_RUNTIME_DIR` pointing to the matching build. No version files change. diff --git a/benchmarks/string_slice/fixed-artifacts.json b/benchmarks/string_slice/fixed-artifacts.json new file mode 100644 index 0000000000..c3f1cec780 --- /dev/null +++ b/benchmarks/string_slice/fixed-artifacts.json @@ -0,0 +1,8 @@ +{ + "source_revision": "0c5348ce5694654d8aa6e4477900ebc9fffe67de", + "sha256": { + "target/release/perry.exe": "75c6318c1938254cd0466d03116069e47432e5ba69d90788e59d95b2682bc7ca", + "target/release/perry_runtime.lib": "20ae523b5c57eeb25960c09719d4147c6fc53084e0f6fb290708472a6866f219", + "target/release/perry_stdlib.lib": "675573b11328430592a8859dc4842832be55cb3d119c3bf85c1cfaa13795bff0" + } +} diff --git a/benchmarks/string_slice/fixed.json b/benchmarks/string_slice/fixed.json new file mode 100644 index 0000000000..21c9cb61e9 --- /dev/null +++ b/benchmarks/string_slice/fixed.json @@ -0,0 +1,204 @@ +{ + "revision": "0c5348ce5694654d8aa6e4477900ebc9fffe67de", + "node": "v26.5.1", + "platform": "Windows-11-10.0.26200-SP0", + "processor": "AMD64 Family 25 Model 116 Stepping 1, AuthenticAMD", + "workloads": { + "ascii": { + "sha256": "3895cd80f04d760f98c3447607347ff5ea0c8d900129a187ddfd6f55be100bfb", + "engines": { + "node": [ + { + "name": "string-slice-parse-loop-ascii", + "category": "strings", + "n": 100, + "ms_per_run": 0.0067449426837486726, + "runs": 19904, + "checksum": 464151292 + }, + { + "name": "string-slice-parse-loop-ascii", + "category": "strings", + "n": 1000, + "ms_per_run": 0.08074435483871113, + "runs": 1718, + "checksum": 710929850 + }, + { + "name": "string-slice-parse-loop-ascii", + "category": "strings", + "n": 10000, + "ms_per_run": 0.8042599999999948, + "runs": 175, + "checksum": 535454277 + }, + { + "name": "string-slice-parse-loop-ascii", + "category": "strings", + "n": 100000, + "ms_per_run": 7.428233333333348, + "runs": 21, + "checksum": 35382078 + }, + { + "name": "string-slice-parse-loop-ascii", + "category": "strings", + "n": 1000000, + "ms_per_run": 71.59320000000002, + "runs": 7, + "checksum": 153135489 + } + ], + "perry": [ + { + "name": "string-slice-parse-loop-ascii", + "category": "strings", + "n": 100, + "ms_per_run": 0.005188482490272402, + "runs": 26905, + "checksum": 464151292, + "checksum_match": true + }, + { + "name": "string-slice-parse-loop-ascii", + "category": "strings", + "n": 1000, + "ms_per_run": 0.05281160949868106, + "runs": 2656, + "checksum": 710929850, + "checksum_match": true + }, + { + "name": "string-slice-parse-loop-ascii", + "category": "strings", + "n": 10000, + "ms_per_run": 0.5264789473684213, + "runs": 269, + "checksum": 535454277, + "checksum_match": true + }, + { + "name": "string-slice-parse-loop-ascii", + "category": "strings", + "n": 100000, + "ms_per_run": 5.251350000000002, + "runs": 28, + "checksum": 35382078, + "checksum_match": true + }, + { + "name": "string-slice-parse-loop-ascii", + "category": "strings", + "n": 1000000, + "ms_per_run": 52.08860000000004, + "runs": 7, + "checksum": 153135489, + "checksum_match": true + } + ] + }, + "slopes": { + "node": 1.001556039034457, + "perry": 1.0000946220402622 + } + }, + "unicode": { + "sha256": "ca1ea8b7ce414346c145e60062c2dbe2be6209f78e203df1567f9f86da9efedb", + "engines": { + "node": [ + { + "name": "string-slice-parse-loop-unicode", + "category": "strings", + "n": 100, + "ms_per_run": 0.01004327309236978, + "runs": 14125, + "checksum": 319467163 + }, + { + "name": "string-slice-parse-loop-unicode", + "category": "strings", + "n": 1000, + "ms_per_run": 0.10087537688442307, + "runs": 1435, + "checksum": 431622199 + }, + { + "name": "string-slice-parse-loop-unicode", + "category": "strings", + "n": 10000, + "ms_per_run": 1.004910000000001, + "runs": 142, + "checksum": 36132863 + }, + { + "name": "string-slice-parse-loop-unicode", + "category": "strings", + "n": 100000, + "ms_per_run": 8.955499999999992, + "runs": 21, + "checksum": 49951631 + }, + { + "name": "string-slice-parse-loop-unicode", + "category": "strings", + "n": 1000000, + "ms_per_run": 89.63160000000005, + "runs": 7, + "checksum": 481167302 + } + ], + "perry": [ + { + "name": "string-slice-parse-loop-unicode", + "category": "strings", + "n": 100, + "ms_per_run": 0.006948454324418352, + "runs": 20095, + "checksum": 319467163, + "checksum_match": true + }, + { + "name": "string-slice-parse-loop-unicode", + "category": "strings", + "n": 1000, + "ms_per_run": 0.07011328671328723, + "runs": 1994, + "checksum": 431622199, + "checksum_match": true + }, + { + "name": "string-slice-parse-loop-unicode", + "category": "strings", + "n": 10000, + "ms_per_run": 0.699286206896551, + "runs": 202, + "checksum": 36132863, + "checksum_match": true + }, + { + "name": "string-slice-parse-loop-unicode", + "category": "strings", + "n": 100000, + "ms_per_run": 7.232333333333344, + "runs": 21, + "checksum": 49951631, + "checksum_match": true + }, + { + "name": "string-slice-parse-loop-unicode", + "category": "strings", + "n": 1000000, + "ms_per_run": 69.96260000000007, + "runs": 7, + "checksum": 481167302, + "checksum_match": true + } + ] + }, + "slopes": { + "node": 0.9849476421934262, + "perry": 1.0019433575448282 + } + } + } +} diff --git a/benchmarks/string_slice/validation.json b/benchmarks/string_slice/validation.json new file mode 100644 index 0000000000..183dba195a --- /dev/null +++ b/benchmarks/string_slice/validation.json @@ -0,0 +1,89 @@ +{ + "source_revision": "0c5348ce5694654d8aa6e4477900ebc9fffe67de", + "fixtures": [ + { + "source": "benchmarks/string_slice/string-slice-astral.ts", + "node_exit": 0, + "perry_exit": 0, + "stdout_match": true, + "stdout_sha256": "f03c3c3ddab4baed9e5c2b65edbce7bda6779c5bd63f027379320f4c2a427b04" + }, + { + "source": "test-files/test_gap_string_slice_utf16_suffix.ts", + "node_exit": 0, + "perry_exit": 0, + "stdout_match": true, + "stdout_sha256": "45fda4b878798c05d5fb83427a820a5a13c79de2d9c09df5b9779f83dc7900e4" + }, + { + "source": "test-files/test_gap_gc_string_suffix_cursor.ts", + "node_exit": 0, + "perry_exit": 0, + "stdout_match": true, + "stdout_sha256": "af4e08e8b27149114752421a04c6b1e5fe4df47e9bfb76098bd2457084cede50", + "gc_schedule": { + "forced_collections": 1061, + "copying_minors": 1061, + "moved_objects": 168, + "loop_polls": 1055 + } + } + ], + "runtime_suite": { + "passed": 3439, + "failed": 1, + "ignored": 4, + "failure": "gc::tests::telemetry_verifier::emergency_full_trace_is_excluded_from_ordinary_pause_stats" + }, + "codegen_suite": { + "passed": 1460, + "failed": 3, + "ignored": 1, + "failures": [ + "codegen::spec_preserve_none_tests::the_clone_entry_is_shrink_wrapped_frameless", + "native_emit::tests::split_native_construction_propagates_shadow_backend_to_workers", + "native_emit::tests::split_native_construction_lowers_precise_roots_before_rs4gc" + ], + "build": "candidate build; final native fixtures rechecked on the final build" + }, + "broader_string_parity": { + "build": "initial candidate build; the final build reran all three focused fixtures above", + "summary": { + "parity_pass": 47, + "parity_fail": 1, + "compile_fail": 19, + "crash_fail": 0, + "node_fail": 0, + "skipped": 1, + "total_run": 48, + "parity_percentage": 97.9 + }, + "failures": { + "parity": [ + "test_gap_tolocalestring_locale_options_9414" + ], + "compile": [ + "test_gap_2786_2880_2782_2789_string_semantics", + "test_gap_5591_method_string_coercion", + "test_gap_6370_tostring_coercion_own_override", + "test_gap_9713_dynamic_number_tostring", + "test_gap_bigint_tolocalestring_intl_gate", + "test_gap_dynamic_string_length_generic_tower", + "test_gap_gc_string_repeat_reentrant_count", + "test_gap_json_direct_strings", + "test_gap_number_string_2864_2948_2855", + "test_gap_object_string_wrappers", + "test_gap_object_tolocalestring_primitive_receiver", + "test_gap_response_boxed_string_body_5453", + "test_gap_sso_concat_string_index", + "test_gap_string_locale_2781_2845_2897", + "test_issue_317_string_matchall", + "test_issue_8432_string_accumulators", + "test_issue_9810_virtual_string_indices", + "test_parity_querystring", + "test_parity_string_decoder" + ], + "crash": [] + } + } +} From 6183863dde4c28f0aa2f1732522be7fa2d1beb87 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Fri, 11 Sep 2026 15:20:10 +0200 Subject: [PATCH 3/3] docs: associate string slice changelog with PR 10075 --- changelog.d/{10061-string-slice.md => 10075-string-slice.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{10061-string-slice.md => 10075-string-slice.md} (100%) diff --git a/changelog.d/10061-string-slice.md b/changelog.d/10075-string-slice.md similarity index 100% rename from changelog.d/10061-string-slice.md rename to changelog.d/10075-string-slice.md