From ef305daa7811c5fbbb9d5f782d95078205675f2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 13:01:00 +0200 Subject: [PATCH 1/3] perf(string): reuse repeated trims and scan only whitespace edges --- benchmarks/string_trim/README.md | 98 ++++ .../string_trim/baseline-artifacts.sha256 | 3 + benchmarks/string_trim/baseline-cold.json | 110 ++++ benchmarks/string_trim/baseline.json | 386 ++++++++++++++ benchmarks/string_trim/fixed.json | 484 ++++++++++++++++++ benchmarks/string_trim/measure.py | 90 ++++ benchmarks/string_trim/string-trim-ascii.ts | 89 ++++ benchmarks/string_trim/string-trim-unicode.ts | 89 ++++ changelog.d/10054-string-trim.md | 11 + crates/perry-runtime/src/gc/mod.rs | 1 + .../src/gc/tests/runtime_roots.rs | 1 + .../src/gc/tests/runtime_roots/string_trim.rs | 87 ++++ crates/perry-runtime/src/gc/tests/support.rs | 1 + crates/perry-runtime/src/string/mod.rs | 3 + crates/perry-runtime/src/string/slice_ops.rs | 107 +++- crates/perry-runtime/src/string/trim_cache.rs | 99 ++++ crates/perry-runtime/src/string/trim_tests.rs | 270 ++++++++++ scripts/gc_runtime_root_holders.json | 4 +- test-files/test_gap_string_trim_boundaries.ts | 30 ++ 19 files changed, 1941 insertions(+), 22 deletions(-) create mode 100644 benchmarks/string_trim/README.md create mode 100644 benchmarks/string_trim/baseline-artifacts.sha256 create mode 100644 benchmarks/string_trim/baseline-cold.json create mode 100644 benchmarks/string_trim/baseline.json create mode 100644 benchmarks/string_trim/fixed.json create mode 100644 benchmarks/string_trim/measure.py create mode 100644 benchmarks/string_trim/string-trim-ascii.ts create mode 100644 benchmarks/string_trim/string-trim-unicode.ts create mode 100644 changelog.d/10054-string-trim.md create mode 100644 crates/perry-runtime/src/gc/tests/runtime_roots/string_trim.rs create mode 100644 crates/perry-runtime/src/string/trim_cache.rs create mode 100644 crates/perry-runtime/src/string/trim_tests.rs create mode 100644 test-files/test_gap_string_trim_boundaries.ts diff --git a/benchmarks/string_trim/README.md b/benchmarks/string_trim/README.md new file mode 100644 index 0000000000..55a6c08c67 --- /dev/null +++ b/benchmarks/string_trim/README.md @@ -0,0 +1,98 @@ +# Repeated string trimming (#10054) + +Current main at `603b074ace01464bc66fc07cc8d532f26ccf5a0f` reproduced the issue before implementation. Both original standalone sources are copied unchanged from #10054. The fixed measurements use this PR’s runtime patch over that revision; `fixed.json` records the exact `git diff HEAD -- crates/` SHA-256 and compiler/runtime archive hashes. + +## Change and limits + +Trim scans the requested whitespace edges and subtracts their UTF-16 lengths from the source header. A cold result is copied through a rooted source handle, re-read after allocation. An unchanged managed string can be shared safely; foreign strings retain the copying contract. + +For retained results of at least 256 bytes, a single-entry per-thread cache reuses the last source/result pair and trim mode. Both pointers are strong, rewritable GC roots. A 32 MiB combined capacity/header budget bounds retention, including spare source capacity; the GC’s own allocation metadata is additional. Replacement drops the previous pair. Cached sources and returned aliases are marked shared to prevent in-place append from changing them. + +This removes full-interior work from repeated trims of a reused immutable receiver. **Cold trims, cache evictions, results below 256 bytes, and inputs exceeding the retention budget still copy the retained bytes in O(n).** Malformed WTF-8 with ambiguous reverse boundaries uses the historical bounded forward scan. The flat StringHeader/codegen/FFI ABI is preserved; general substring views remain separate representation work relevant to #10061. + +## Method + +- Apple M1 Max, 10 logical cores, macOS 26.5, arm64; Node v26.5.1; Perry 0.5.1532. +- Compiler and both matching static archives built together with `CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 cargo build --release --locked -j 4 -p perry -p perry-runtime-static -p perry-stdlib-static`. +- Every fixture compiled with `--no-auto-optimize` and an explicit `PERRY_RUNTIME_DIR`. +- Sizes 100, 1,000, 10,000, 100,000, 1,000,000, in that order; Node then Perry, sequentially. Original warmup (at least 200 ms and five runs), seven samples of at least 20 ms, and 60-second per-process timeout retained. +- All five generated-source hashes match the before run. All 25 before/after checksum pairs match Node, including both original Unicode and ASCII workloads through one million tokens. +- This is a contended shared host. Other work ran during the sweeps, so exact constant-factor comparisons are not controlled. The Node medians also vary substantially. The robust result is the removal of interior-length scaling for the repeated-input trim work; repeat on an idle host for precise ratios. + +Load averages: original baseline start/end `[66.587890625, 63.22119140625, 58.33251953125]` / `[31.310546875, 52.17822265625, 54.625]`; alternating baseline `[23.7412109375, 47.94580078125, 52.9814453125]` / `[27.30078125, 47.900390625, 52.90576171875]`; fixed sweep `[64.9306640625, 64.12451171875, 62.9033203125]` / `[55.21435546875, 63.736328125, 63.16015625]`. + +## Original ASCII workload + +All times below are median milliseconds per 16-trim workload invocation. Each ratio uses the matching Node measurement from that sweep. + +| n | Node before | Perry before | Before / Node | Node after | Perry after | After / Node | +|---:|---:|---:|---:|---:|---:|---:| +| 100 | 0.014689 | 0.028460 | 1.94× | 0.034925 | 0.034734 | 0.99× | +| 1,000 | 0.013383 | 0.173485 | 12.96× | 0.032738 | 0.034824 | 1.06× | +| 10,000 | 0.014670 | 1.686392 | 114.96× | 0.028254 | 0.035096 | 1.24× | +| 100,000 | 0.014566 | 16.730479 | 1148.62× | 0.049022 | 0.028340 | 0.58× | +| 1,000,000 | 0.014982 | 171.586958 | 11453.21× | 0.043383 | 0.037424 | 0.86× | + +Perry fitted exponent: **0.954 → -0.002**. + +The fixed exponent meets the issue’s ≤0.25 target. + +## Original Unicode workload + +| n | Node before | Perry before | Before / Node | Node after | Perry after | After / Node | +|---:|---:|---:|---:|---:|---:|---:| +| 100 | 0.014747 | 0.236148 | 16.01× | 0.067698 | 0.537645 | 7.94× | +| 1,000 | 0.013858 | 2.280791 | 164.58× | 0.149693 | 7.471167 | 49.91× | +| 10,000 | 0.013893 | 22.489500 | 1618.79× | 0.171185 | 87.803917 | 512.92× | +| 100,000 | 0.014943 | 206.249875 | 13802.09× | 0.081558 | 1096.120542 | 13439.78× | +| 1,000,000 | 0.014706 | 3910.500792 | 265912.70× | 0.131619 | 2035.471833 | 15464.83× | + +Perry fitted exponent: **1.039 → 0.932**. + +## Separate Unicode costs + +`unicode-trim-only` replaces `hashString(input.trim())` with `input.trim().length`. `unicode-checksum-only` trims the input once in setup, outside the timers, then hashes that retained input 16 times. They retain the original timing driver. Both controls have matching Node checksums at every size. + +| n | Trim before | Trim after | Indexed checksum before | Indexed checksum after | +|---:|---:|---:|---:|---:| +| 100 | 0.050435 | 0.000146 | 0.207776 | 0.251263 | +| 1,000 | 0.412874 | 0.000207 | 1.977652 | 3.120101 | +| 10,000 | 5.184156 | 0.000134 | 19.563500 | 33.208917 | +| 100,000 | 52.680416 | 0.000121 | 176.898625 | 347.406042 | +| 1,000,000 | 945.056333 | 0.000143 | 1866.769917 | 2324.477791 | + +Trim-only fitted exponent: 1.065 → -0.025. The indexed checksum remains linear (0.998); that code is unchanged and is tracked by #10055. These are separate runs under variable host load, so their times should not be subtracted or expected to add exactly to the combined workload. The remaining Unicode ratio is not attributed to trim. + +## Cache-miss control + +`string-trim-alternating-ascii` alternates two distinct prebuilt inputs, one with an extra retained `x`. This deliberately replaces the single-entry cache on each trim and exposes the remaining copy cost. Setup remains outside the timers. + +| n | Node before | Perry before | Before / Node | Node after | Perry after | After / Node | +|---:|---:|---:|---:|---:|---:|---:| +| 100 | 0.041673 | 0.066735 | 1.60× | 0.018384 | 0.019752 | 1.07× | +| 1,000 | 0.108987 | 1.060643 | 9.73× | 0.021098 | 0.031742 | 1.50× | +| 10,000 | 0.040129 | 4.153041 | 103.49× | 0.020626 | 0.121460 | 5.89× | +| 100,000 | 0.035648 | 44.763833 | 1255.71× | 0.016727 | 1.020254 | 60.99× | +| 1,000,000 | 0.049176 | 328.138917 | 6672.69× | 0.015629 | 10.768861 | 689.03× | + +Perry fitted exponent: **0.901 → 0.698**. + +The cache-miss workload still scales with the retained payload. Boundary scanning and known UTF-16 lengths reduce its cost, but the representation-imposed copy remains. + +## Reproduce + +Run from the checkout whose matching compiler and archives were built: + +```sh +python3 benchmarks/string_trim/measure.py before +python3 benchmarks/string_trim/measure.py after +``` + +Use `--runtime-dir` and `--output-dir` to select other matching build artifacts and keep each checkout’s results. `--only` accepts workload names. The script writes binaries, exact generated sources, compile logs and JSON measurements below `target/string-trim` by default. The original baseline used the same generated source bytes and timing loop; its two sweeps are preserved separately in `baseline.json` and `baseline-cold.json`. + +## Validation + +- Runtime unit suite: 3,534 passed, 4 ignored, zero failures, single-threaded. Includes all new trim tests, existing malformed guard-page coverage, and string-copy moving-GC tests. The new cache test asserts that both cached strings actually relocate and that a subsequent trim reuses the relocated result. +- New compiled trim fixture matches Node byte-for-byte. +- GC root-holder audit and test registration checks pass. +- Broad `pre-tag-check.sh --quick` passes except for the pre-existing public benchmark freshness failure. All public source/harness fingerprint inputs are byte-identical to the base commit; published source fingerprint `bec8afb6e384640aa9090036e3ad393ac564750a083f6e6c485804b6784c38b8` differs from the base/current `9507434be47f7bb383c30810bdc5d66d9be65290da529f38b7473860ea98f75e`. diff --git a/benchmarks/string_trim/baseline-artifacts.sha256 b/benchmarks/string_trim/baseline-artifacts.sha256 new file mode 100644 index 0000000000..ae18c6314a --- /dev/null +++ b/benchmarks/string_trim/baseline-artifacts.sha256 @@ -0,0 +1,3 @@ +63cb83cf87044443184687ac5eee3376a21dd46c7a1cbf3074789926f99d7b36 target/release/perry +3c0467d1d7ebcd8bd53e98c43e027ff7e93b306024f68b9ba453f383fb8a6fb4 target/release/libperry_runtime.a +a13fd68f3b01f814c7fcd031f57b34e978bc00612317f3c20bef68928f7067af target/release/libperry_stdlib.a diff --git a/benchmarks/string_trim/baseline-cold.json b/benchmarks/string_trim/baseline-cold.json new file mode 100644 index 0000000000..bc0216ba92 --- /dev/null +++ b/benchmarks/string_trim/baseline-cold.json @@ -0,0 +1,110 @@ +{ + "label": "baseline-cold", + "sha": "603b074ace01464bc66fc07cc8d532f26ccf5a0f", + "node": "v26.5.1", + "host": "macOS-26.5-arm64-arm-64bit-Mach-O", + "load_start": [ + 23.7412109375, + 47.94580078125, + 52.9814453125 + ], + "benchmarks": { + "string-trim-alternating-ascii": { + "source_sha256": "f2694b0826e66fca3e9f96c070fb6bbe067b5df86c42cf1e12be352e6f19e50f", + "binary_sha256": "f124c2b81994cbd52848a6c2e26820f20b452c3f5c10560a8b96f56046fdb056", + "node": [ + { + "name": "string-trim-alternating-ascii", + "category": "strings", + "n": 100, + "ms_per_run": 0.04167295218295361, + "runs": 3589, + "checksum": 7377983936 + }, + { + "name": "string-trim-alternating-ascii", + "category": "strings", + "n": 1000, + "ms_per_run": 0.10898668932038805, + "runs": 3665, + "checksum": 4795209384 + }, + { + "name": "string-trim-alternating-ascii", + "category": "strings", + "n": 10000, + "ms_per_run": 0.04012868006430803, + "runs": 3467, + "checksum": 15015235456 + }, + { + "name": "string-trim-alternating-ascii", + "category": "strings", + "n": 100000, + "ms_per_run": 0.03564822864651799, + "runs": 5720, + "checksum": 12234515976 + }, + { + "name": "string-trim-alternating-ascii", + "category": "strings", + "n": 1000000, + "ms_per_run": 0.049176405405405514, + "runs": 3475, + "checksum": 6321236336 + } + ], + "perry": [ + { + "name": "string-trim-alternating-ascii", + "category": "strings", + "n": 100, + "ms_per_run": 0.06673537931034441, + "runs": 2227, + "checksum": 7377983936 + }, + { + "name": "string-trim-alternating-ascii", + "category": "strings", + "n": 1000, + "ms_per_run": 1.0606426315789577, + "runs": 270, + "checksum": 4795209384 + }, + { + "name": "string-trim-alternating-ascii", + "category": "strings", + "n": 10000, + "ms_per_run": 4.153041399999995, + "runs": 53, + "checksum": 15015235456 + }, + { + "name": "string-trim-alternating-ascii", + "category": "strings", + "n": 100000, + "ms_per_run": 44.76383299999998, + "runs": 8, + "checksum": 12234515976 + }, + { + "name": "string-trim-alternating-ascii", + "category": "strings", + "n": 1000000, + "ms_per_run": 328.1389170000007, + "runs": 7, + "checksum": 6321236336 + } + ], + "slopes": { + "node": -0.03415304801391869, + "perry": 0.900876140194046 + } + } + }, + "load_end": [ + 27.30078125, + 47.900390625, + 52.90576171875 + ] +} diff --git a/benchmarks/string_trim/baseline.json b/benchmarks/string_trim/baseline.json new file mode 100644 index 0000000000..6ad7dfb4d0 --- /dev/null +++ b/benchmarks/string_trim/baseline.json @@ -0,0 +1,386 @@ +{ + "label": "baseline", + "sha": "603b074ace01464bc66fc07cc8d532f26ccf5a0f", + "node": "v26.5.1", + "host": "macOS-26.5-arm64-arm-64bit-Mach-O", + "load_start": [ + 66.587890625, + 63.22119140625, + 58.33251953125 + ], + "benchmarks": { + "string-trim-ascii": { + "source_sha256": "72458cfb33c59bb3899a40effa9dc185b17da92eb61b71d3bc41bfa11550cee6", + "binary_sha256": "dd7ce5e0a69068802baa66866e36a2a9f81e73690f5718b72cfc529eeb4ebb5a", + "node": [ + { + "name": "string-trim-ascii", + "category": "strings", + "n": 100, + "ms_per_run": 0.01468867914831128, + "runs": 9558, + "checksum": 3299494000 + }, + { + "name": "string-trim-ascii", + "category": "strings", + "n": 1000, + "ms_per_run": 0.013382818729096843, + "runs": 10481, + "checksum": 3920510048 + }, + { + "name": "string-trim-ascii", + "category": "strings", + "n": 10000, + "ms_per_run": 0.014669740469208624, + "runs": 9290, + "checksum": 14059261744 + }, + { + "name": "string-trim-ascii", + "category": "strings", + "n": 100000, + "ms_per_run": 0.014565685589519916, + "runs": 9651, + "checksum": 10385466752 + }, + { + "name": "string-trim-ascii", + "category": "strings", + "n": 1000000, + "ms_per_run": 0.014981558801498344, + "runs": 9379, + "checksum": 4515886736 + } + ], + "perry": [ + { + "name": "string-trim-ascii", + "category": "strings", + "n": 100, + "ms_per_run": 0.028459995732574545, + "runs": 4933, + "checksum": 3299494000 + }, + { + "name": "string-trim-ascii", + "category": "strings", + "n": 1000, + "ms_per_run": 0.1734845689655217, + "runs": 809, + "checksum": 3920510048 + }, + { + "name": "string-trim-ascii", + "category": "strings", + "n": 10000, + "ms_per_run": 1.6863922499999877, + "runs": 84, + "checksum": 14059261744 + }, + { + "name": "string-trim-ascii", + "category": "strings", + "n": 100000, + "ms_per_run": 16.730479000000017, + "runs": 14, + "checksum": 10385466752 + }, + { + "name": "string-trim-ascii", + "category": "strings", + "n": 1000000, + "ms_per_run": 171.5869580000001, + "runs": 7, + "checksum": 4515886736 + } + ], + "slopes": { + "node": 0.005393185322456966, + "perry": 0.9544746412234775 + } + }, + "string-trim-unicode": { + "source_sha256": "1affa3053211a1eb0e73c6242f6cf192356d60269729f88dcee32abecea76b2b", + "binary_sha256": "da95c3f5e59a558959c2462533dd7f41a13d8251b8e28b98bfa5b6e020883dc6", + "node": [ + { + "name": "string-trim-unicode", + "category": "strings", + "n": 100, + "ms_per_run": 0.014746795873249965, + "runs": 9419, + "checksum": 15682954032 + }, + { + "name": "string-trim-unicode", + "category": "strings", + "n": 1000, + "ms_per_run": 0.013858312326869512, + "runs": 10122, + "checksum": 14356907120 + }, + { + "name": "string-trim-unicode", + "category": "strings", + "n": 10000, + "ms_per_run": 0.013892763888888462, + "runs": 10023, + "checksum": 7400533984 + }, + { + "name": "string-trim-unicode", + "category": "strings", + "n": 100000, + "ms_per_run": 0.014943374906646977, + "runs": 9392, + "checksum": 11699376128 + }, + { + "name": "string-trim-unicode", + "category": "strings", + "n": 1000000, + "ms_per_run": 0.014705957352940276, + "runs": 9468, + "checksum": 8362401136 + } + ], + "perry": [ + { + "name": "string-trim-unicode", + "category": "strings", + "n": 100, + "ms_per_run": 0.23614772093023462, + "runs": 582, + "checksum": 15682954032 + }, + { + "name": "string-trim-unicode", + "category": "strings", + "n": 1000, + "ms_per_run": 2.28079144444444, + "runs": 63, + "checksum": 14356907120 + }, + { + "name": "string-trim-unicode", + "category": "strings", + "n": 10000, + "ms_per_run": 22.48950000000002, + "runs": 7, + "checksum": 7400533984 + }, + { + "name": "string-trim-unicode", + "category": "strings", + "n": 100000, + "ms_per_run": 206.24987499999975, + "runs": 7, + "checksum": 11699376128 + }, + { + "name": "string-trim-unicode", + "category": "strings", + "n": 1000000, + "ms_per_run": 3910.500792000006, + "runs": 7, + "checksum": 8362401136 + } + ], + "slopes": { + "node": 0.003032961441315215, + "perry": 1.0394405358086467 + } + }, + "unicode-trim-only": { + "source_sha256": "9e6f7211b748d602dd99e4ef025b5c1fb654899597fa0d923d0d4425e10091ae", + "binary_sha256": "8902a71e4a79ca117730b054d8b1bea6830b7107548bd5bb5a8046fe511f4f8a", + "node": [ + { + "name": "unicode-trim-only", + "category": "strings", + "n": 100, + "ms_per_run": 0.0005169916446432292, + "runs": 365273, + "checksum": 8000 + }, + { + "name": "unicode-trim-only", + "category": "strings", + "n": 1000, + "ms_per_run": 0.0007543998648851869, + "runs": 232032, + "checksum": 80000 + }, + { + "name": "unicode-trim-only", + "category": "strings", + "n": 10000, + "ms_per_run": 0.0004525200278496948, + "runs": 321680, + "checksum": 800000 + }, + { + "name": "unicode-trim-only", + "category": "strings", + "n": 100000, + "ms_per_run": 0.00044860763580129704, + "runs": 357182, + "checksum": 8000000 + }, + { + "name": "unicode-trim-only", + "category": "strings", + "n": 1000000, + "ms_per_run": 0.0007148840498333171, + "runs": 256712, + "checksum": 80000000 + } + ], + "perry": [ + { + "name": "unicode-trim-only", + "category": "strings", + "n": 100, + "ms_per_run": 0.0504350403022664, + "runs": 3137, + "checksum": 8000 + }, + { + "name": "unicode-trim-only", + "category": "strings", + "n": 1000, + "ms_per_run": 0.4128735185185161, + "runs": 270, + "checksum": 80000 + }, + { + "name": "unicode-trim-only", + "category": "strings", + "n": 10000, + "ms_per_run": 5.184155999999987, + "runs": 32, + "checksum": 800000 + }, + { + "name": "unicode-trim-only", + "category": "strings", + "n": 100000, + "ms_per_run": 52.68041600000004, + "runs": 7, + "checksum": 8000000 + }, + { + "name": "unicode-trim-only", + "category": "strings", + "n": 1000000, + "ms_per_run": 945.0563330000004, + "runs": 7, + "checksum": 80000000 + } + ], + "slopes": { + "node": 0.00557692257201056, + "perry": 1.0651282816503316 + } + }, + "unicode-checksum-only": { + "source_sha256": "708a6e5cc18d2e66740a2f2587b093fa2ce0d092fc87cf6631334c1e67ada24d", + "binary_sha256": "ee7c9f34552c5a4046ec123328dc1b34a3e59321f705efb0d726e59e7a1de784", + "node": [ + { + "name": "unicode-checksum-only", + "category": "strings", + "n": 100, + "ms_per_run": 0.01592436783439415, + "runs": 8833, + "checksum": 15682954032 + }, + { + "name": "unicode-checksum-only", + "category": "strings", + "n": 1000, + "ms_per_run": 0.014281409707351764, + "runs": 9480, + "checksum": 14356907120 + }, + { + "name": "unicode-checksum-only", + "category": "strings", + "n": 10000, + "ms_per_run": 0.013918752261656193, + "runs": 9958, + "checksum": 7400533984 + }, + { + "name": "unicode-checksum-only", + "category": "strings", + "n": 100000, + "ms_per_run": 0.014644904831624376, + "runs": 9527, + "checksum": 11699376128 + }, + { + "name": "unicode-checksum-only", + "category": "strings", + "n": 1000000, + "ms_per_run": 0.014655506959707137, + "runs": 9522, + "checksum": 8362401136 + } + ], + "perry": [ + { + "name": "unicode-checksum-only", + "category": "strings", + "n": 100, + "ms_per_run": 0.20777630927835022, + "runs": 672, + "checksum": 15682954032 + }, + { + "name": "unicode-checksum-only", + "category": "strings", + "n": 1000, + "ms_per_run": 1.9776517272727367, + "runs": 76, + "checksum": 14356907120 + }, + { + "name": "unicode-checksum-only", + "category": "strings", + "n": 10000, + "ms_per_run": 19.563499500000006, + "runs": 13, + "checksum": 7400533984 + }, + { + "name": "unicode-checksum-only", + "category": "strings", + "n": 100000, + "ms_per_run": 176.89862500000004, + "runs": 7, + "checksum": 11699376128 + }, + { + "name": "unicode-checksum-only", + "category": "strings", + "n": 1000000, + "ms_per_run": 1866.7699169999978, + "runs": 7, + "checksum": 8362401136 + } + ], + "slopes": { + "node": -0.006120723399870104, + "perry": 0.9858564176241698 + } + } + }, + "load_end": [ + 31.310546875, + 52.17822265625, + 54.625 + ] +} diff --git a/benchmarks/string_trim/fixed.json b/benchmarks/string_trim/fixed.json new file mode 100644 index 0000000000..d6814c1fb3 --- /dev/null +++ b/benchmarks/string_trim/fixed.json @@ -0,0 +1,484 @@ +{ + "label": "fixed", + "sha": "603b074ace01464bc66fc07cc8d532f26ccf5a0f", + "diff_sha256": "2c207799927f17bacfd7c6aa7ffb8107eef00642ded3a006f1bed7a5838cbda1", + "node": "v26.5.1", + "host": "macOS-26.5-arm64-arm-64bit-Mach-O", + "load_start": [ + 64.9306640625, + 64.12451171875, + 62.9033203125 + ], + "artifacts": { + "perry": "fa3859c877cdd3a43ee4c15af6c83c3637b3c93440031890f91f3142e6f022e8", + "libperry_runtime.a": "d1c6ddd96e83627152940d1b12a48f29e31878ee5955e8233e00612355ca00fd", + "libperry_stdlib.a": "29a8e7923345e959ec28206013c83f6d4ef7a780d9bd86cebc9ba9599cfd3c36" + }, + "benchmarks": { + "string-trim-ascii": { + "source_sha256": "72458cfb33c59bb3899a40effa9dc185b17da92eb61b71d3bc41bfa11550cee6", + "binary_sha256": "2b29f95b9bba9af60d33d84e45b36d0ff06ba82a63c520d099d313a0a456e76a", + "node": [ + { + "name": "string-trim-ascii", + "category": "strings", + "n": 100, + "ms_per_run": 0.03492472474513219, + "runs": 4299, + "checksum": 3299494000 + }, + { + "name": "string-trim-ascii", + "category": "strings", + "n": 1000, + "ms_per_run": 0.032737512274959794, + "runs": 4853, + "checksum": 3920510048 + }, + { + "name": "string-trim-ascii", + "category": "strings", + "n": 10000, + "ms_per_run": 0.028253940677965512, + "runs": 4696, + "checksum": 14059261744 + }, + { + "name": "string-trim-ascii", + "category": "strings", + "n": 100000, + "ms_per_run": 0.04902156862745132, + "runs": 3644, + "checksum": 10385466752 + }, + { + "name": "string-trim-ascii", + "category": "strings", + "n": 1000000, + "ms_per_run": 0.043383222780571246, + "runs": 3115, + "checksum": 4515886736 + } + ], + "perry": [ + { + "name": "string-trim-ascii", + "category": "strings", + "n": 100, + "ms_per_run": 0.03473420486111061, + "runs": 3582, + "checksum": 3299494000 + }, + { + "name": "string-trim-ascii", + "category": "strings", + "n": 1000, + "ms_per_run": 0.034824278884462254, + "runs": 7007, + "checksum": 3920510048 + }, + { + "name": "string-trim-ascii", + "category": "strings", + "n": 10000, + "ms_per_run": 0.03509597719298364, + "runs": 4254, + "checksum": 14059261744 + }, + { + "name": "string-trim-ascii", + "category": "strings", + "n": 100000, + "ms_per_run": 0.028339707428572766, + "runs": 5849, + "checksum": 10385466752 + }, + { + "name": "string-trim-ascii", + "category": "strings", + "n": 1000000, + "ms_per_run": 0.03742374234234248, + "runs": 3949, + "checksum": 4515886736 + } + ], + "slopes": { + "node": 0.036371916785269, + "perry": -0.0024707063737694542 + } + }, + "string-trim-unicode": { + "source_sha256": "1affa3053211a1eb0e73c6242f6cf192356d60269729f88dcee32abecea76b2b", + "binary_sha256": "4b8d8cb60ee518fc5ac53fed9b067f227f637626c6138729cfc2de00d20bee5a", + "node": [ + { + "name": "string-trim-unicode", + "category": "strings", + "n": 100, + "ms_per_run": 0.06769777000000052, + "runs": 2475, + "checksum": 15682954032 + }, + { + "name": "string-trim-unicode", + "category": "strings", + "n": 1000, + "ms_per_run": 0.14969311724138262, + "runs": 2112, + "checksum": 14356907120 + }, + { + "name": "string-trim-unicode", + "category": "strings", + "n": 10000, + "ms_per_run": 0.1711846940298514, + "runs": 1885, + "checksum": 7400533984 + }, + { + "name": "string-trim-unicode", + "category": "strings", + "n": 100000, + "ms_per_run": 0.08155791760299791, + "runs": 3452, + "checksum": 11699376128 + }, + { + "name": "string-trim-unicode", + "category": "strings", + "n": 1000000, + "ms_per_run": 0.13161943949044536, + "runs": 2213, + "checksum": 8362401136 + } + ], + "perry": [ + { + "name": "string-trim-unicode", + "category": "strings", + "n": 100, + "ms_per_run": 0.5376445306122485, + "runs": 277, + "checksum": 15682954032 + }, + { + "name": "string-trim-unicode", + "category": "strings", + "n": 1000, + "ms_per_run": 7.471166666666666, + "runs": 36, + "checksum": 14356907120 + }, + { + "name": "string-trim-unicode", + "category": "strings", + "n": 10000, + "ms_per_run": 87.80391700000001, + "runs": 7, + "checksum": 7400533984 + }, + { + "name": "string-trim-unicode", + "category": "strings", + "n": 100000, + "ms_per_run": 1096.1205419999987, + "runs": 7, + "checksum": 11699376128 + }, + { + "name": "string-trim-unicode", + "category": "strings", + "n": 1000000, + "ms_per_run": 2035.4718329999996, + "runs": 7, + "checksum": 8362401136 + } + ], + "slopes": { + "node": 0.03137556441281421, + "perry": 0.9322809621669077 + } + }, + "unicode-trim-only": { + "source_sha256": "9e6f7211b748d602dd99e4ef025b5c1fb654899597fa0d923d0d4425e10091ae", + "binary_sha256": "f75f7ebf254e77eec5782d9904b1fadc82b60d3e05d398eebda87c8279d3a40e", + "node": [ + { + "name": "unicode-trim-only", + "category": "strings", + "n": 100, + "ms_per_run": 0.00022296470129290678, + "runs": 621794, + "checksum": 8000 + }, + { + "name": "unicode-trim-only", + "category": "strings", + "n": 1000, + "ms_per_run": 0.0003132938199377659, + "runs": 448994, + "checksum": 80000 + }, + { + "name": "unicode-trim-only", + "category": "strings", + "n": 10000, + "ms_per_run": 0.00022532224375300386, + "runs": 600544, + "checksum": 800000 + }, + { + "name": "unicode-trim-only", + "category": "strings", + "n": 100000, + "ms_per_run": 0.0002287298604757235, + "runs": 623859, + "checksum": 8000000 + }, + { + "name": "unicode-trim-only", + "category": "strings", + "n": 1000000, + "ms_per_run": 0.0003369410912765759, + "runs": 431440, + "checksum": 80000000 + } + ], + "perry": [ + { + "name": "unicode-trim-only", + "category": "strings", + "n": 100, + "ms_per_run": 0.00014562546691038712, + "runs": 922233, + "checksum": 8000 + }, + { + "name": "unicode-trim-only", + "category": "strings", + "n": 1000, + "ms_per_run": 0.00020745437571975968, + "runs": 713997, + "checksum": 80000 + }, + { + "name": "unicode-trim-only", + "category": "strings", + "n": 10000, + "ms_per_run": 0.00013396866438689306, + "runs": 1043234, + "checksum": 800000 + }, + { + "name": "unicode-trim-only", + "category": "strings", + "n": 100000, + "ms_per_run": 0.00012135694912168917, + "runs": 1029813, + "checksum": 8000000 + }, + { + "name": "unicode-trim-only", + "category": "strings", + "n": 1000000, + "ms_per_run": 0.0001427140879542425, + "runs": 940201, + "checksum": 80000000 + } + ], + "slopes": { + "node": 0.022200676712678082, + "perry": -0.025039891855106047 + } + }, + "unicode-checksum-only": { + "source_sha256": "708a6e5cc18d2e66740a2f2587b093fa2ce0d092fc87cf6631334c1e67ada24d", + "binary_sha256": "b0f1f4b783e67c3c792e1a1ed6940d28a7d00b5a050a58063272f99c0fd10663", + "node": [ + { + "name": "unicode-checksum-only", + "category": "strings", + "n": 100, + "ms_per_run": 0.018056776173285362, + "runs": 7858, + "checksum": 15682954032 + }, + { + "name": "unicode-checksum-only", + "category": "strings", + "n": 1000, + "ms_per_run": 0.024820789044289332, + "runs": 5208, + "checksum": 14356907120 + }, + { + "name": "unicode-checksum-only", + "category": "strings", + "n": 10000, + "ms_per_run": 0.019555223851417242, + "runs": 7445, + "checksum": 7400533984 + }, + { + "name": "unicode-checksum-only", + "category": "strings", + "n": 100000, + "ms_per_run": 0.03407243270868689, + "runs": 4857, + "checksum": 11699376128 + }, + { + "name": "unicode-checksum-only", + "category": "strings", + "n": 1000000, + "ms_per_run": 0.02402114765906348, + "runs": 5613, + "checksum": 8362401136 + } + ], + "perry": [ + { + "name": "unicode-checksum-only", + "category": "strings", + "n": 100, + "ms_per_run": 0.2512634999999982, + "runs": 578, + "checksum": 15682954032 + }, + { + "name": "unicode-checksum-only", + "category": "strings", + "n": 1000, + "ms_per_run": 3.120101428571437, + "runs": 53, + "checksum": 14356907120 + }, + { + "name": "unicode-checksum-only", + "category": "strings", + "n": 10000, + "ms_per_run": 33.208917000000014, + "runs": 7, + "checksum": 7400533984 + }, + { + "name": "unicode-checksum-only", + "category": "strings", + "n": 100000, + "ms_per_run": 347.40604200000007, + "runs": 7, + "checksum": 11699376128 + }, + { + "name": "unicode-checksum-only", + "category": "strings", + "n": 1000000, + "ms_per_run": 2324.477791000003, + "runs": 7, + "checksum": 8362401136 + } + ], + "slopes": { + "node": 0.03854946353981505, + "perry": 0.9979060647155222 + } + }, + "string-trim-alternating-ascii": { + "source_sha256": "f2694b0826e66fca3e9f96c070fb6bbe067b5df86c42cf1e12be352e6f19e50f", + "binary_sha256": "47c17fc64fc3b41c9ff54c1b025b5d0a0428cb89d93c2166b6c8abefce99223f", + "node": [ + { + "name": "string-trim-alternating-ascii", + "category": "strings", + "n": 100, + "ms_per_run": 0.018384160845588738, + "runs": 7711, + "checksum": 7377983936 + }, + { + "name": "string-trim-alternating-ascii", + "category": "strings", + "n": 1000, + "ms_per_run": 0.021098146624473332, + "runs": 6052, + "checksum": 4795209384 + }, + { + "name": "string-trim-alternating-ascii", + "category": "strings", + "n": 10000, + "ms_per_run": 0.02062634536082424, + "runs": 6535, + "checksum": 15015235456 + }, + { + "name": "string-trim-alternating-ascii", + "category": "strings", + "n": 100000, + "ms_per_run": 0.016727370401337954, + "runs": 8233, + "checksum": 12234515976 + }, + { + "name": "string-trim-alternating-ascii", + "category": "strings", + "n": 1000000, + "ms_per_run": 0.015628998437501228, + "runs": 8665, + "checksum": 6321236336 + } + ], + "perry": [ + { + "name": "string-trim-alternating-ascii", + "category": "strings", + "n": 100, + "ms_per_run": 0.01975178243902461, + "runs": 6626, + "checksum": 7377983936 + }, + { + "name": "string-trim-alternating-ascii", + "category": "strings", + "n": 1000, + "ms_per_run": 0.031742448170730894, + "runs": 5061, + "checksum": 4795209384 + }, + { + "name": "string-trim-alternating-ascii", + "category": "strings", + "n": 10000, + "ms_per_run": 0.12146032727273312, + "runs": 1170, + "checksum": 15015235456 + }, + { + "name": "string-trim-alternating-ascii", + "category": "strings", + "n": 100000, + "ms_per_run": 1.0202541904761964, + "runs": 142, + "checksum": 12234515976 + }, + { + "name": "string-trim-alternating-ascii", + "category": "strings", + "n": 1000000, + "ms_per_run": 10.768860999999987, + "runs": 17, + "checksum": 6321236336 + } + ], + "slopes": { + "node": -0.02418419590262806, + "perry": 0.6980194924076883 + } + } + }, + "load_end": [ + 55.21435546875, + 63.736328125, + 63.16015625 + ] +} diff --git a/benchmarks/string_trim/measure.py b/benchmarks/string_trim/measure.py new file mode 100644 index 0000000000..fb1bb6ec34 --- /dev/null +++ b/benchmarks/string_trim/measure.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Sequential, checksum-gated #10054 sweep; build matching release artifacts first.""" + +import argparse +import hashlib +import json +import math +import os +import platform +import subprocess +from pathlib import Path + + +def slope(rows): + x = [math.log(row["n"]) for row in rows] + y = [math.log(row["ms_per_run"]) for row in rows] + mx, my = sum(x) / len(x), sum(y) / len(y) + return sum((a - mx) * (b - my) for a, b in zip(x, y)) / sum((a - mx) ** 2 for a in x) + + +def sources(directory): + ascii_source = (directory / "string-trim-ascii.ts").read_text() + unicode_source = (directory / "string-trim-unicode.ts").read_text() + alternating = ascii_source.replace( + "function setup(n: number): string { return ' \\t' + \"aBcD\".repeat(n) + '\\n '; }", + "function setup(n: number): string[] { return [' \\t' + \"aBcD\".repeat(n) + '\\n ', ' \\t' + \"aBcD\".repeat(n) + 'x\\n ']; }", + ).replace("function run(input: string)", "function run(input: string[])") + return { + "string-trim-ascii": ascii_source, + "string-trim-unicode": unicode_source, + "unicode-trim-only": unicode_source.replace("hashString(input.trim())", "input.trim().length").replace("string-trim-unicode", "unicode-trim-only"), + "unicode-checksum-only": unicode_source.replace("const preparedInput = setup(n);", "const preparedInput = setup(n).trim();").replace("hashString(input.trim())", "hashString(input)").replace("string-trim-unicode", "unicode-checksum-only"), + "string-trim-alternating-ascii": alternating.replace("input.trim()", "input[i % 2].trim()").replace("string-trim-ascii", "string-trim-alternating-ascii"), + } + + +def sha256(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("label") + parser.add_argument("--output-dir", type=Path, default=Path("target/string-trim")) + parser.add_argument("--runtime-dir", type=Path, default=Path("target/release")) + parser.add_argument("--only", nargs="+") + args = parser.parse_args() + output = args.output_dir.resolve() + runtime = args.runtime_dir.resolve() + output.mkdir(parents=True, exist_ok=True) + env = dict(os.environ, PERRY_RUNTIME_DIR=str(runtime), TZ="UTC", LC_ALL="en_US.UTF-8") + result = { + "label": args.label, + "sha": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), + "diff_sha256": hashlib.sha256(subprocess.check_output(["git", "diff", "HEAD", "--", "crates/"])).hexdigest(), + "node": subprocess.check_output(["node", "--version"], text=True).strip(), + "host": platform.platform(), + "load_start": os.getloadavg(), + "artifacts": {name: sha256(runtime / name) for name in ["perry", "libperry_runtime.a", "libperry_stdlib.a"]}, + "benchmarks": {}, + } + for name, source_text in sources(Path(__file__).resolve().parent).items(): + if args.only and name not in args.only: + continue + source = output / (name + ".ts") + source.write_text(source_text) + binary = output / (args.label + "-" + name) + compiled = subprocess.run([str(runtime / "perry"), "compile", str(source), "--no-auto-optimize", "-o", str(binary)], env=env, capture_output=True, text=True) + (output / (args.label + "-" + name + "-compile.log")).write_text(compiled.stdout + compiled.stderr) + compiled.check_returncode() + bench = {"source_sha256": sha256(source), "binary_sha256": sha256(binary), "node": [], "perry": []} + for n in [100, 1000, 10000, 100000, 1000000]: + for engine, command in [("node", ["node", str(source)]), ("perry", [str(binary)])]: + run = subprocess.run(command + [str(n)], env=env, capture_output=True, text=True, timeout=60) + if run.returncode: + raise RuntimeError((name, engine, n, run.returncode, run.stdout, run.stderr)) + row = json.loads(run.stdout) + bench[engine].append(row) + print(args.label, name, engine, row, flush=True) + if bench["node"][-1]["checksum"] != bench["perry"][-1]["checksum"]: + raise AssertionError((name, n, "checksum mismatch")) + bench["slopes"] = {engine: slope(bench[engine]) for engine in ["node", "perry"]} + print("slopes", name, bench["slopes"], flush=True) + result["benchmarks"][name] = bench + result["load_end"] = os.getloadavg() + (output / (args.label + ".json")).write_text(json.dumps(result, indent=2) + "\n") + + +if __name__ == "__main__": + main() diff --git a/benchmarks/string_trim/string-trim-ascii.ts b/benchmarks/string_trim/string-trim-ascii.ts new file mode 100644 index 0000000000..2f1a9fee05 --- /dev/null +++ b/benchmarks/string_trim/string-trim-ascii.ts @@ -0,0 +1,89 @@ +// @runtime {"name": "string-trim-ascii", "category": "strings", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/string/slice_ops.rs", "function": "js_string_trim"}, {"file": "crates/perry-runtime/src/string/slice_ops.rs", "function": "js_whitespace_trim_range"}, {"file": "crates/perry-runtime/src/string/slice_ops.rs", "function": "trim_impl"}], "hypothesis": "Hypothesis: js_whitespace_trim_range scans the whole interior forward to find the final non-whitespace sequence, then trim_impl allocates/copies the full result. Nearly constant Node timings are consistent with boundary scanning and shared substring storage, but that V8 mechanism is an inference.", "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. Only two whitespace characters at each edge; 16 trims per run expose interior-size scaling on reused immutable input. Perry source establishes O(n) scanning plus copying even though the trimmed edges are constant-sized. V8 internals were not inspected; its substring-sharing explanation remains a hypothesis.", "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 ' \t' + "aBcD".repeat(n) + '\n '; } +function run(input: string): number { + let h = 0; + for (let i = 0; i < 16; i++) h += hashString(input.trim()); + 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-trim-ascii", category: "strings", n, + ms_per_run: samples[3], runs, checksum})); +} +benchmarkMain(); diff --git a/benchmarks/string_trim/string-trim-unicode.ts b/benchmarks/string_trim/string-trim-unicode.ts new file mode 100644 index 0000000000..a9209052e6 --- /dev/null +++ b/benchmarks/string_trim/string-trim-unicode.ts @@ -0,0 +1,89 @@ +// @runtime {"name": "string-trim-unicode", "category": "strings", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/string/slice_ops.rs", "function": "js_string_trim"}, {"file": "crates/perry-runtime/src/string/slice_ops.rs", "function": "js_whitespace_trim_range"}, {"file": "crates/perry-runtime/src/string/slice_ops.rs", "function": "trim_impl"}, {"file": "crates/perry-runtime/src/string/char_ops.rs", "function": "js_string_char_code_at"}, {"file": "crates/perry-runtime/src/string/char_ops.rs", "function": "utf16_unit_at"}], "hypothesis": "Hypothesis: js_whitespace_trim_range scans the whole interior forward to find the final non-whitespace sequence, then trim_impl allocates/copies the full result. Nearly constant Node timings are consistent with boundary scanning and shared substring storage, but that V8 mechanism is an inference. Unicode result hashing adds repeated UTF-16 prefix scans.", "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. Only two whitespace characters at each edge; 16 trims per run expose interior-size scaling on reused immutable input. Perry source establishes O(n) scanning plus copying even though the trimmed edges are constant-sized. V8 internals were not inspected; its substring-sharing explanation remains a hypothesis. The timed Unicode checksum adds roughly 32 prefix scans per trimmed result, O(n) total per trim; its cost is included in the ratio.", "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 ' \t' + "ä中😀Ö".repeat(n) + '\n '; } +function run(input: string): number { + let h = 0; + for (let i = 0; i < 16; i++) h += hashString(input.trim()); + 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-trim-unicode", category: "strings", n, + ms_per_run: samples[3], runs, checksum})); +} +benchmarkMain(); diff --git a/changelog.d/10054-string-trim.md b/changelog.d/10054-string-trim.md new file mode 100644 index 0000000000..2171ae3ee3 --- /dev/null +++ b/changelog.d/10054-string-trim.md @@ -0,0 +1,11 @@ +String trimming now finds trailing whitespace from the end and derives the +result's UTF-16 length from the removed edges. Repeated long trims reuse the +last immutable source/result pair under a 32 MiB capacity budget, avoiding +repeated interior scans and copies. Cold or evicted trims still copy the +retained bytes required by the flat string representation. + +Both cached pointers participate in moving-GC root scanning. Copying re-reads +a rooted source after allocation; unchanged foreign strings are still copied +to preserve their external lifetime contract. Regression coverage includes +ECMAScript whitespace, lone surrogates, malformed WTF-8, append aliasing, +cache bounds, and actual nursery relocation. Unicode indexing is unchanged. diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 271328b3f0..af6f42b3ce 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -1124,6 +1124,7 @@ pub fn gc_init() { reg_scanner!(crate::intl::segmenter::scan_segment_record_keys_roots_mut); reg_scanner!(small_int_cache_mutable_root_scanner); reg_scanner!(concat_memo_mutable_root_scanner); + reg_scanner!(crate::string::trim_cache::scan_trim_cache_roots_mut); reg_scanner!(crate::builtins::scan_console_log_singleton_roots_mut); reg_scanner!(crate::builtins::scan_structured_clone_memo_roots_mut); // #8282/#8294: process EventEmitter listener closures live as raw diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots.rs b/crates/perry-runtime/src/gc/tests/runtime_roots.rs index ba9900fec3..c0ed324faf 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots.rs @@ -22,6 +22,7 @@ mod segment_record_keys; mod side_table_scanners; mod string_normalize_form; mod string_slice; +mod string_trim; mod symbol_description; mod thenable_assimilation; mod transient_handles; diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/string_trim.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/string_trim.rs new file mode 100644 index 0000000000..c4de3e1984 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/string_trim.rs @@ -0,0 +1,87 @@ +use super::*; +use crate::string::{self, trim_cache}; + +fn assert_payload(source: *const crate::StringHeader, expected: &[u8]) { + assert_eq!( + unsafe { string::OwnedStringBytes::copy_from_header(source) }.as_bytes(), + expected + ); +} + +struct TrimCacheGuard; + +impl TrimCacheGuard { + fn new() -> Self { + trim_cache::test_clear_trim_cache(); + Self + } +} + +impl Drop for TrimCacheGuard { + fn drop(&mut self) { + trim_cache::test_clear_trim_cache(); + } +} + +#[test] +fn trim_cache_keeps_and_rewrites_both_strings_during_copying_gc() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _cache = TrimCacheGuard::new(); + register_runtime_handle_root_scanner_for_tests(); + gc_register_mutable_root_scanner(trim_cache::scan_trim_cache_roots_mut); + + let bytes = format!(" \t{}\n ", "aBcD".repeat(1000)); + let source = string::js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); + let result = string::js_string_trim(source); + assert!(crate::arena::pointer_in_nursery(source as usize)); + assert!(crate::arena::pointer_in_nursery(result as usize)); + + // The cache is the only registered root for either string. The raw stack + // locals deliberately cannot keep them alive under this test guard. + let _ = gc_collect_minor(); + let (source_now, result_now) = trim_cache::test_trim_cache_pair(); + assert_ne!(source_now, source, "the source must actually move"); + assert_ne!(result_now, result, "the result must actually move"); + assert_payload(source_now, bytes.as_bytes()); + assert_payload(result_now, &bytes.as_bytes()[2..bytes.len() - 2]); + assert_eq!(string::js_string_trim(source_now), result_now); +} + +#[test] +fn trim_roots_source_across_destination_allocation() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); + let _guard = CopyingNurseryTestGuard::new(0); + let triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _cache = TrimCacheGuard::new(); + register_runtime_handle_root_scanner_for_tests(); + gc_register_mutable_root_scanner(trim_cache::scan_trim_cache_roots_mut); + + let bytes = format!(" \t{}\n ", "ä中😀Ö".repeat(300)); + let source = string::js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); + assert!(crate::arena::pointer_in_nursery(source as usize)); + force_next_general_arena_alloc_slow(); + triggers.make_arena_trigger_due(); + let before = gc_collection_count(); + let result = string::js_string_trim(source); + let scope = RuntimeHandleScope::new(); + let result_root = scope.root_string_ptr(result); + drain_scheduled_minor_gc(before, "trim destination allocation"); + let result_now = result_root.get_raw_const_ptr::(); + assert_payload(result_now, &bytes.as_bytes()[2..bytes.len() - 2]); + assert_eq!(unsafe { (*result_now).utf16_len }, 1500); + let (source_now, cached_result) = trim_cache::test_trim_cache_pair(); + assert_payload(source_now, bytes.as_bytes()); + assert_eq!(cached_result.cast_const(), result_now); +} + +#[test] +fn trim_cache_scanner_is_registered() { + crate::gc::gc_init(); + assert!(crate::gc::roots::MUTABLE_ROOT_SCANNERS.with(|scanners| { + scanners.borrow().iter().any(|entry| { + entry.scanner as usize + == trim_cache::scan_trim_cache_roots_mut as MutableRootScanner as usize + }) + })); +} diff --git a/crates/perry-runtime/src/gc/tests/support.rs b/crates/perry-runtime/src/gc/tests/support.rs index d0cbecce02..5f13c8a7c1 100644 --- a/crates/perry-runtime/src/gc/tests/support.rs +++ b/crates/perry-runtime/src/gc/tests/support.rs @@ -432,6 +432,7 @@ pub(super) fn reset_copying_nursery_runtime_test_state() { crate::object::test_clear_arguments_object_roots(); crate::symbol::test_clear_symbol_side_table_roots(); crate::json::test_clear_parse_roots(); + crate::string::trim_cache::test_clear_trim_cache(); crate::set::test_clear_set_roots(); crate::os::test_clear_process_event_listeners(); crate::promise::test_clear_promise_scanner_roots(); diff --git a/crates/perry-runtime/src/string/mod.rs b/crates/perry-runtime/src/string/mod.rs index a284456320..22b0d85958 100644 --- a/crates/perry-runtime/src/string/mod.rs +++ b/crates/perry-runtime/src/string/mod.rs @@ -119,12 +119,15 @@ mod pad; mod raw; mod slice_ops; mod split; +pub(crate) mod trim_cache; mod utf16_count; #[cfg(feature = "regex-engine")] pub(crate) use split::{spec_fancy_regex_split, spec_regex_split}; #[cfg(test)] mod tests; +#[cfg(test)] +mod trim_tests; /// #6085 guard-page regression tests: prove no string scanner reads past the /// end of an exact-sized payload. Unix-only (needs `mmap` + `mprotect`). diff --git a/crates/perry-runtime/src/string/slice_ops.rs b/crates/perry-runtime/src/string/slice_ops.rs index 3463f27127..0270b5c66a 100644 --- a/crates/perry-runtime/src/string/slice_ops.rs +++ b/crates/perry-runtime/src/string/slice_ops.rs @@ -237,7 +237,11 @@ fn js_whitespace_seq_at(bytes: &[u8], i: usize) -> (bool, usize) { /// /// Trimming behavior on valid input is unchanged; a truncated/invalid tail is /// never treated as whitespace, so it survives the trim byte-for-byte. -fn js_whitespace_trim_range(bytes: &[u8], trim_start: bool, trim_end: bool) -> (usize, usize) { +pub(super) fn js_whitespace_trim_range( + bytes: &[u8], + trim_start: bool, + trim_end: bool, +) -> (usize, usize) { let mut start = 0usize; if trim_start { while start < bytes.len() { @@ -251,23 +255,54 @@ fn js_whitespace_trim_range(bytes: &[u8], trim_start: bool, trim_end: bool) -> ( let mut end = bytes.len(); if trim_end { - // Forward-walk from `start`, remembering the end of the last - // non-whitespace sequence. A reverse WTF-8 walk would have to guess - // sequence boundaries; this stays O(n) and never reads out of range. - let mut i = start; - let mut last_non_ws_end = start; - while i < bytes.len() { - let (is_ws, advance) = js_whitespace_seq_at(bytes, i); - let next = (i + advance).min(bytes.len()); - if !is_ws { - last_non_ws_end = next; + end = js_whitespace_trim_end(bytes, start); + } + + (start, end.max(start)) +} + +/// Walk only the trailing edge for valid UTF-8/WTF-8. At most four bytes +/// identify a candidate sequence. Malformed payloads can have ambiguous +/// boundaries (a lead can consume an ASCII space or another lead), so use the +/// historical bounded forward walk for those instead of guessing. +fn js_whitespace_trim_end(bytes: &[u8], start: usize) -> usize { + let mut end = bytes.len(); + while end > start { + let mut candidate = end - 1; + while candidate > start && bytes[candidate] & 0xc0 == 0x80 && end - candidate < 4 { + candidate -= 1; + } + // A lead in the preceding three bytes must not nominally extend into + // this candidate. Valid text never does; malformed text may, even when + // the candidate itself looks like a complete whitespace sequence. + for i in candidate.saturating_sub(3).max(start)..candidate { + if bytes[i] >= 0xc0 && i + wtf8_step(bytes, i).0 > candidate { + return js_whitespace_trim_end_forward(bytes, start); } - i = next; } - end = last_non_ws_end; + let (is_ws, advance) = js_whitespace_seq_at(&bytes[..end], candidate); + if candidate + advance != end { + return js_whitespace_trim_end_forward(bytes, start); + } + if !is_ws { + break; + } + end = candidate; } + end +} - (start, end.max(start)) +fn js_whitespace_trim_end_forward(bytes: &[u8], start: usize) -> usize { + let mut i = start; + let mut end = start; + while i < bytes.len() { + let (is_ws, advance) = js_whitespace_seq_at(bytes, i); + i = (i + advance).min(bytes.len()); + if !is_ws { + end = i; + } + } + end } /// Shared trim entry point over the raw payload bytes. @@ -275,16 +310,48 @@ fn trim_impl(s: *const StringHeader, trim_start: bool, trim_end: bool) -> *mut S if !is_valid_string_ptr(s) { return js_string_from_bytes(ptr::null(), 0); } + let mode = u8::from(trim_start) | (u8::from(trim_end) << 1); + if unsafe { (*s).byte_len } >= trim_cache::MIN_CACHED_BYTES { + let cached = trim_cache::lookup(s, mode); + if !cached.is_null() { + return cached; + } + } let bytes = unsafe { slice::from_raw_parts(string_data(s), (*s).byte_len as usize) }; let (start, end) = js_whitespace_trim_range(bytes, trim_start, trim_end); - let out = &bytes[start..end]; + if start == end { + return js_string_from_bytes(ptr::null(), 0); + } + if start == 0 + && end == bytes.len() + && !matches!( + crate::arena::classify_heap_space(s as usize), + crate::arena::HeapSpace::Unknown + ) + { + // Returning the receiver creates an alias. Do not leave a unique + // append buffer mutable. Foreign storage must still be copied because + // a GC root cannot extend the external owner's lifetime. + if unsafe { (*s).refcount } != 0 { + js_string_addref(s.cast_mut()); + } + return s.cast_mut(); + } + // Count only removed edges. Recounting the retained payload would restore + // O(interior length) scanning even after the reverse boundary walk. + let removed_units = + compute_utf16_len_wtf8(&bytes[..start]) + compute_utf16_len_wtf8(&bytes[end..]); + let utf16_len = unsafe { (*s).utf16_len }.saturating_sub(removed_units); // Preserve the WTF-8 flag: trimming only removes well-formed whitespace, so // any lone surrogate in the source survives into the result. - let flags = unsafe { (*s).flags }; - if flags & STRING_FLAG_HAS_LONE_SURROGATES != 0 { - return js_string_from_wtf8_bytes(out.as_ptr(), out.len() as u32); - } - js_string_from_bytes(out.as_ptr(), out.len() as u32) + let flags = unsafe { (*s).flags } & STRING_FLAG_HAS_LONE_SURROGATES; + let scope = crate::gc::RuntimeHandleScope::new(); + let source = scope.root_string_ptr(s); + // string_copy_range re-reads its rooted source AFTER allocating. Never + // hand the allocator a borrowed pointer into the moving source payload. + let result = string_copy_range(s, start, (end - start) as u32, utf16_len, flags); + trim_cache::remember(source.get_raw_const_ptr::(), result, mode); + result } /// Trim whitespace from both ends of a string diff --git a/crates/perry-runtime/src/string/trim_cache.rs b/crates/perry-runtime/src/string/trim_cache.rs new file mode 100644 index 0000000000..f3185a9058 --- /dev/null +++ b/crates/perry-runtime/src/string/trim_cache.rs @@ -0,0 +1,99 @@ +//! Reuse the last long trim of an immutable source without another payload copy. +//! +//! StringHeader's inline payload is also the codegen/FFI ABI, so a cold trim +//! still copies its retained range. This single-entry, per-thread cache makes +//! repeated trims allocation-free without changing that representation. Both +//! pointers are strong, rewritable GC roots, never pinned. Replacement releases +//! the previous pair; the capacity budget bounds retained storage even for a +//! short string backed by a large append buffer. Small results are cheap to copy +//! and must not keep a large source alive just for a few bytes. + +use super::*; +use std::cell::UnsafeCell; + +pub(super) const MAX_RETAINED_BYTES: u64 = 32 * 1024 * 1024; +pub(super) const MIN_CACHED_BYTES: u32 = 256; + +struct TrimCache { + source: *mut StringHeader, + result: *mut StringHeader, + mode: u8, +} + +thread_local! { + static TRIM_CACHE: UnsafeCell = const { UnsafeCell::new(TrimCache { + source: ptr::null_mut(), + result: ptr::null_mut(), + mode: 0, + }) }; +} + +pub(super) fn lookup(source: *const StringHeader, mode: u8) -> *mut StringHeader { + TRIM_CACHE.with(|cell| unsafe { + let cache = &*cell.get(); + if ptr::eq(cache.source, source) && cache.mode == mode { + cache.result + } else { + ptr::null_mut() + } + }) +} + +fn store(source: *mut StringHeader, result: *mut StringHeader, mode: u8) { + TRIM_CACHE.with(|cell| unsafe { + let cache = &mut *cell.get(); + // GC_STORE_AUDIT(ROOT): both slots are visited by scan_trim_cache_roots_mut. + crate::gc::runtime_store_root_raw_mut_ptr_slot(&raw mut cache.source, source); + crate::gc::runtime_store_root_raw_mut_ptr_slot(&raw mut cache.result, result); + cache.mode = mode; + }); +} + +pub(super) fn remember(source: *const StringHeader, result: *mut StringHeader, mode: u8) { + unsafe { + if (*result).byte_len < MIN_CACHED_BYTES { + return; + } + let retained = u64::from((*source).capacity) + + u64::from((*result).capacity) + + 2 * std::mem::size_of::() as u64; + // FFI callers and guard-page tests can supply non-GC headers. A root + // cannot extend their external lifetime, so never retain those pointers. + if retained > MAX_RETAINED_BYTES + || matches!( + crate::arena::classify_heap_space(source as usize), + crate::arena::HeapSpace::Unknown + ) + { + store(ptr::null_mut(), ptr::null_mut(), 0); + return; + } + // A cached source must not be changed by a later in-place +=. The + // freshly copied result already has the shared refcount hint. + js_string_addref(source.cast_mut()); + debug_assert_eq!((*result).refcount, 0); + store(source.cast_mut(), result, mode); + } +} + +pub(crate) fn scan_trim_cache_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { + TRIM_CACHE.with(|cell| unsafe { + let cache = &mut *cell.get(); + for slot in [&mut cache.source, &mut cache.result] { + let mut addr = *slot as usize; + if visitor.visit_tagged_usize_slot(&mut addr, crate::value::STRING_TAG) { + *slot = addr as *mut StringHeader; + } + } + }); +} + +#[cfg(test)] +pub(crate) fn test_clear_trim_cache() { + store(ptr::null_mut(), ptr::null_mut(), 0); +} + +#[cfg(test)] +pub(crate) fn test_trim_cache_pair() -> (*mut StringHeader, *mut StringHeader) { + TRIM_CACHE.with(|cell| unsafe { ((*cell.get()).source, (*cell.get()).result) }) +} diff --git a/crates/perry-runtime/src/string/trim_tests.rs b/crates/perry-runtime/src/string/trim_tests.rs new file mode 100644 index 0000000000..1cbcfb322e --- /dev/null +++ b/crates/perry-runtime/src/string/trim_tests.rs @@ -0,0 +1,270 @@ +use super::*; + +struct TrimTestGuard { + _suppress: crate::gc::GcSuppressScope, +} + +impl TrimTestGuard { + fn new() -> Self { + trim_cache::test_clear_trim_cache(); + Self { + _suppress: crate::gc::GcSuppressScope::new(), + } + } +} + +impl Drop for TrimTestGuard { + fn drop(&mut self) { + trim_cache::test_clear_trim_cache(); + } +} + +fn make(bytes: &[u8]) -> *mut StringHeader { + js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32) +} + +fn payload(s: *const StringHeader) -> Vec { + unsafe { OwnedStringBytes::copy_from_header(s).as_bytes().to_vec() } +} + +#[test] +fn trim_ecmascript_whitespace_and_non_whitespace() { + let _guard = TrimTestGuard::new(); + for cp in [ + 0x9, 0xa, 0xb, 0xc, 0xd, 0x20, 0xa0, 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, + 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200a, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000, + 0xfeff, + ] { + let ws = char::from_u32(cp).unwrap(); + let source = make(format!("{ws}ä中😀Ö{ws}").as_bytes()); + for (trim, expected) in [ + ( + js_string_trim as extern "C" fn(_) -> _, + "ä中😀Ö".to_string(), + ), + (js_string_trim_start, format!("ä中😀Ö{ws}")), + (js_string_trim_end, format!("{ws}ä中😀Ö")), + ] { + let result = trim(source); + assert_eq!(payload(result), expected.as_bytes(), "U+{cp:04X}"); + assert_eq!( + unsafe { (*result).utf16_len }, + expected.encode_utf16().count() as u32 + ); + } + } + for source in ["", "plain", "\u{0085}keep\u{0085}", "\u{200b}keep\u{200b}"] { + let s = make(source.as_bytes()); + assert_eq!(payload(js_string_trim(s)), source.as_bytes()); + } + for source in [" \t\n", "\u{feff}\u{3000}\u{a0}"] { + let empty = js_string_trim(make(source.as_bytes())); + js_string_addref(empty); + assert_eq!(payload(empty), b""); + } +} + +#[test] +fn trim_preserves_lone_surrogates_and_utf16_length() { + let _guard = TrimTestGuard::new(); + let bytes = b" \t\xed\xa0\x80\xe4\xb8\xad\xf0\x9f\x98\x80\xed\xbf\xbf\n "; + let source = js_string_from_wtf8_bytes(bytes.as_ptr(), bytes.len() as u32); + let result = js_string_trim(source); + assert_eq!(payload(result), &bytes[2..bytes.len() - 2]); + assert_eq!(unsafe { (*result).utf16_len }, 5); + assert_ne!( + unsafe { (*result).flags } & STRING_FLAG_HAS_LONE_SURROGATES, + 0 + ); + assert_eq!(js_string_char_code_at(result, 0), 0xd800 as f64); + assert_eq!(js_string_char_code_at(result, 4), 0xdfff as f64); +} + +// Reference the historical forward walk, including its treatment of malformed +// WTF-8. The reverse walk must agree even when a lead consumes bytes that look +// like whitespace or another lead rather than like continuation bytes. +fn forward_range(bytes: &[u8], left: bool, right: bool) -> (usize, usize) { + let mut start = 0; + let whitespace = |i| { + let (advance, units, cp) = wtf8_step(bytes, i); + ( + i + advance <= bytes.len() + && units > 0 + && char::from_u32(cp) + .map(slice_ops::is_js_whitespace) + .unwrap_or(false), + advance, + ) + }; + if left { + while start < bytes.len() { + let (ws, advance) = whitespace(start); + if !ws { + break; + } + start += advance; + } + } + let mut end = bytes.len(); + if right { + let mut i = start; + end = start; + while i < bytes.len() { + let (ws, advance) = whitespace(i); + i = (i + advance).min(bytes.len()); + if !ws { + end = i; + } + } + } + (start, end) +} + +#[test] +fn reverse_trim_matches_forward_walk_on_malformed_payloads() { + let alphabet = [9, 32, 65, 128, 160, 192, 194, 195, 224, 226, 239, 240, 255]; + let mut seed = 10054u64; + for len in 0..32 { + for _ in 0..1024 { + let bytes: Vec = (0..len) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + alphabet[seed as usize % alphabet.len()] + }) + .collect(); + for (left, right) in [(false, true), (true, false), (true, true)] { + assert_eq!( + slice_ops::js_whitespace_trim_range(&bytes, left, right), + forward_range(&bytes, left, right), + "bytes={bytes:x?}, left={left}, right={right}", + ); + } + } + } +} + +#[test] +fn repeated_trim_reuses_result_without_aliasing_append() { + let _guard = TrimTestGuard::new(); + let bytes = format!(" \t{}\n ", "aBcD".repeat(100)); + let source = js_string_from_bytes_with_capacity(bytes.as_ptr(), bytes.len() as u32, 1024); + unsafe { + (*source).refcount = 1; + } + let result = js_string_trim(source); + assert_eq!( + js_string_trim(source), + result, + "repeated trim must avoid another copy" + ); + assert_eq!( + unsafe { (*source).refcount }, + 0, + "a cached source must be immutable" + ); + assert_eq!(unsafe { (*result).refcount }, 0); + let suffix = make(b"tail"); + let appended = js_string_append(source, suffix); + assert_eq!(payload(source), bytes.as_bytes()); + assert_eq!(payload(appended), format!("{bytes}tail").as_bytes()); + assert_eq!(js_string_trim(source), result); + + let identity = js_string_from_bytes_with_capacity(b"unchanged".as_ptr(), 9, 64); + unsafe { + (*identity).refcount = 1; + } + assert_eq!(js_string_trim(identity), identity); + assert_eq!(unsafe { (*identity).refcount }, 0); + assert_eq!( + payload(js_string_append(identity, suffix)), + b"unchangedtail" + ); + assert_eq!(payload(identity), b"unchanged"); +} + +#[test] +fn trim_cache_distinguishes_modes_and_replaces_old_source() { + let _guard = TrimTestGuard::new(); + let inner = "x".repeat(400); + let source = make(format!(" {inner} ").as_bytes()); + for (trim, expected) in [ + (js_string_trim as extern "C" fn(_) -> _, inner.clone()), + (js_string_trim_start, format!("{inner} ")), + (js_string_trim_end, format!(" {inner}")), + ] { + let result = trim(source); + assert_eq!(payload(result), expected.as_bytes()); + assert_eq!(trim(source), result); + } + let next_source = make(format!(" {} ", "y".repeat(400)).as_bytes()); + let next_result = js_string_trim(next_source); + assert_eq!( + trim_cache::test_trim_cache_pair(), + (next_source, next_result) + ); +} + +#[test] +fn trim_cache_budget_counts_spare_capacity() { + let _guard = TrimTestGuard::new(); + let bytes = format!(" {} ", "x".repeat(400)); + let source = js_string_from_bytes_with_capacity( + bytes.as_ptr(), + bytes.len() as u32, + trim_cache::MAX_RETAINED_BYTES as u32, + ); + let result = js_string_trim(source); + assert_eq!(payload(result), &bytes.as_bytes()[1..bytes.len() - 1]); + assert_eq!( + trim_cache::test_trim_cache_pair(), + (ptr::null_mut(), ptr::null_mut()) + ); +} + +#[test] +fn trim_cache_does_not_retain_foreign_string_storage() { + let _guard = TrimTestGuard::new(); + let bytes = format!(" {} ", "x".repeat(400)); + let size = std::mem::size_of::() + bytes.len(); + let mut storage = vec![0u64; size.div_ceil(8)]; + let source = storage.as_mut_ptr().cast::(); + unsafe { + init_string_header( + source, + bytes.len() as u32, + bytes.len() as u32, + bytes.len() as u32, + 0, + 0, + ); + ptr::copy_nonoverlapping(bytes.as_ptr(), string_data(source).cast_mut(), bytes.len()); + } + assert_eq!( + payload(js_string_trim(source)), + &bytes.as_bytes()[1..bytes.len() - 1] + ); + assert_eq!( + trim_cache::test_trim_cache_pair(), + (ptr::null_mut(), ptr::null_mut()) + ); + let mut expected = bytes.into_bytes(); + expected[0] = b'Y'; + let last = expected.len() - 1; + expected[last] = b'Z'; + unsafe { + ptr::copy_nonoverlapping( + expected.as_ptr(), + string_data(source).cast_mut(), + expected.len(), + ); + } + let unchanged = js_string_trim(source); + assert_ne!( + unchanged, source, + "an unchanged foreign payload must still be copied" + ); + drop(storage); + assert_eq!(payload(unchanged), expected); +} diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 5cba919405..68801b4530 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -289,7 +289,7 @@ "file": "crates/perry-runtime/src/gc/census.rs", "name": "PASS1_MARKED", "verdict": "non_moving_snapshot", - "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module \u2014 all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed.", + "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module \u2014 all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -306,7 +306,7 @@ "sources": { "crates/perry-runtime/src/gc/census.rs": "0fd14b011bdbe0ae561e105d6d1acf0e9cc03e8a86ddef12685e307d1adee55a", "crates/perry-runtime/src/gc/cycle.rs": "77eadaf7c4157308c3b14be5e1ff11d7198b84b503a50ff2d244851248d3e800", - "crates/perry-runtime/src/gc/mod.rs": "74fbcbd4c0a1ea991e06251526ce54854d8f74cd35c8ab9ac6a8c3894a7ebc05", + "crates/perry-runtime/src/gc/mod.rs": "895f02ccffffbbac563cff3234bef2fc22840fb6198f185111edea066539ca97", "crates/perry-runtime/src/gc/policy.rs": "a701257f2e2310adabe16e33c0afcd935c7cd28e1ddc157b4974b48e2688cc4f", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } diff --git a/test-files/test_gap_string_trim_boundaries.ts b/test-files/test_gap_string_trim_boundaries.ts new file mode 100644 index 0000000000..b08b6326c0 --- /dev/null +++ b/test-files/test_gap_string_trim_boundaries.ts @@ -0,0 +1,30 @@ +const whitespace = [0x9, 0xa, 0xb, 0xc, 0xd, 0x20, 0xa0, 0x1680, + 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, + 0x2008, 0x2009, 0x200a, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000, 0xfeff]; +function units(s: string): string { + let out = ''; + for (let i = 0; i < s.length; i++) out += s.charCodeAt(i) + ','; + return out; +} +for (const cp of whitespace) { + const ws = String.fromCharCode(cp); + const input = ws + 'a\ud800中😀\udfffz' + ws; + console.log(cp, units(input.trim()), units(input.trimStart()), units(input.trimEnd())); +} +for (const input of ['', 'plain', ' \t\r\n ', '\tleft', 'right\n', '\u0085keep\u0085', '\u200bkeep\u200b']) { + console.log(units(input.trim()), units(input.trimStart()), units(input.trimEnd())); +} +const interior = 'ä中😀Ö'.repeat(100); +let source = ' \t' + interior + '\n '; +const first = source.trim(); +for (let i = 0; i < 30; i++) { + console.log(i, source.trim() === interior, source.trimStart() === interior + '\n ', source.trimEnd() === ' \t' + interior); +} +let result = source.trim(); +source += 'tail'; +result += 'changed'; +console.log(first === interior, result === interior + 'changed', source.trim() === interior + '\n tail'); +let unique = 'prefix' + interior; +const unchanged = unique.trim(); +unique += '!'; +console.log(unchanged === 'prefix' + interior, unique === 'prefix' + interior + '!'); From 88ff7949ecfc0a2b211b0d7ee7ce6104f29354cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 13:09:27 +0200 Subject: [PATCH 2/3] test(string): record baseline parity comparison for trim fix --- benchmarks/string_trim/README.md | 1 + .../parity-baseline-comparison.json | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 benchmarks/string_trim/parity-baseline-comparison.json diff --git a/benchmarks/string_trim/README.md b/benchmarks/string_trim/README.md index 55a6c08c67..9b76c6828e 100644 --- a/benchmarks/string_trim/README.md +++ b/benchmarks/string_trim/README.md @@ -94,5 +94,6 @@ Use `--runtime-dir` and `--output-dir` to select other matching build artifacts - Runtime unit suite: 3,534 passed, 4 ignored, zero failures, single-threaded. Includes all new trim tests, existing malformed guard-page coverage, and string-copy moving-GC tests. The new cache test asserts that both cached strings actually relocate and that a subsequent trim reuses the relocated result. - New compiled trim fixture matches Node byte-for-byte. +- Wider string parity sweep (`PERRY_SKIP_BUILD=1`, matching release compiler/archives, `./scripts/run_gap_tests.sh --filter string`): 63 passes, three output mismatches, one suite skip, no compilation failures or crashes. The mismatches in `test_edge_strings`, `test_gap_5591_method_string_coercion`, and `test_gap_declared_string_local_holds_number_7837` reproduce byte-for-byte after a pristine rebuild of base `603b074a`; they concern existing array/function coercion. `test_issue58_object_string` is explicitly skipped by the unchanged runner. The snapshot gate therefore exits nonzero; its expectations were not modified. `parity-baseline-comparison.json` records artifact, fixture, and output hashes for the A/B check. - GC root-holder audit and test registration checks pass. - Broad `pre-tag-check.sh --quick` passes except for the pre-existing public benchmark freshness failure. All public source/harness fingerprint inputs are byte-identical to the base commit; published source fingerprint `bec8afb6e384640aa9090036e3ad393ac564750a083f6e6c485804b6784c38b8` differs from the base/current `9507434be47f7bb383c30810bdc5d66d9be65290da529f38b7473860ea98f75e`. diff --git a/benchmarks/string_trim/parity-baseline-comparison.json b/benchmarks/string_trim/parity-baseline-comparison.json new file mode 100644 index 0000000000..881f2111ab --- /dev/null +++ b/benchmarks/string_trim/parity-baseline-comparison.json @@ -0,0 +1,49 @@ +{ + "baseline_revision": "603b074ace01464bc66fc07cc8d532f26ccf5a0f", + "fixed_runtime_commit": "ef305daa7811c5fbbb9d5f782d95078205675f2e", + "node": "v26.5.1", + "baseline_artifacts": { + "perry": "63cb83cf87044443184687ac5eee3376a21dd46c7a1cbf3074789926f99d7b36", + "libperry_runtime.a": "3c0467d1d7ebcd8bd53e98c43e027ff7e93b306024f68b9ba453f383fb8a6fb4", + "libperry_stdlib.a": "a13fd68f3b01f814c7fcd031f57b34e978bc00612317f3c20bef68928f7067af" + }, + "fixed_string_suite": { + "parity_pass": 63, + "parity_fail": 3, + "compile_fail": 0, + "crash_fail": 0, + "node_fail": 0, + "skipped": 1, + "total_run": 66, + "parity_percentage": 95.4 + }, + "tests": [ + { + "name": "test_edge_strings", + "source_sha256": "6414eb2430e5683d0fb78d59cc8720d410d8e85ed3a4b73681804211e8f7def3", + "baseline_output_sha256": "a04be48026a774dde491bb16593957ea5c501d74e48749335f5bcb4cb15fc8a0", + "fixed_output_sha256": "a04be48026a774dde491bb16593957ea5c501d74e48749335f5bcb4cb15fc8a0", + "node_output_sha256": "0fb664b1f2be494abd8473aaef0a9d0b68df6afb6bd4d5a03f0610ac8556197c", + "baseline_matches_fixed": true, + "baseline_matches_node": false + }, + { + "name": "test_gap_5591_method_string_coercion", + "source_sha256": "6290ebf789bf31a1e3c71885a00790a0b3d3b3d4a70e4e9665e3981edcbb046e", + "baseline_output_sha256": "9eb504c7de357acb391b762883d28cc3a2f5171032b8c72e165792778d562dcf", + "fixed_output_sha256": "9eb504c7de357acb391b762883d28cc3a2f5171032b8c72e165792778d562dcf", + "node_output_sha256": "5bcc8e21f944e602c2917091fd132333ce247c1d5c3ee4ac8012873cb9e6b244", + "baseline_matches_fixed": true, + "baseline_matches_node": false + }, + { + "name": "test_gap_declared_string_local_holds_number_7837", + "source_sha256": "b59b495c64ec103231497d8db6b77034d42fd8bbaad01112fd33e2b6315839c7", + "baseline_output_sha256": "635e193efbea251189f925b1012ce787b3bea2ac3de19625c90f6b7c11e88508", + "fixed_output_sha256": "635e193efbea251189f925b1012ce787b3bea2ac3de19625c90f6b7c11e88508", + "node_output_sha256": "b20a7ece02b01832965baa3ac52400e16d73502c790ade18bd19809675419ba5", + "baseline_matches_fixed": true, + "baseline_matches_node": false + } + ] +} From fd2422b8b511507206e7ab3cf75081fc5a23e8ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 11 Sep 2026 13:10:19 +0200 Subject: [PATCH 3/3] docs(changelog): key trim fragment to PR 10069 --- changelog.d/{10054-string-trim.md => 10069-string-trim.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{10054-string-trim.md => 10069-string-trim.md} (100%) diff --git a/changelog.d/10054-string-trim.md b/changelog.d/10069-string-trim.md similarity index 100% rename from changelog.d/10054-string-trim.md rename to changelog.d/10069-string-trim.md