Skip to content

Merge train 212: record a string template's replacement pieces natively — 1,870 MB to 108 MB (v0.5.1590) - #10492

Merged
proggeramlug merged 5 commits into
mainfrom
train212r
Sep 17, 2026
Merged

proggeramlug merged 5 commits into
mainfrom
train212r

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

This train lands #10412 as v0.5.1590, on 7661bc05fe. Four source commits, each verified to preserve its patch-id and authorship.

  • perf(regex): record a string template's replacement pieces natively (#10411) #10412 (Fixes #10411) — str.replace(/re/g, "template") was holding about a kilobyte of traced heap per output piece. Pieces recorded every piece as three f64 pushed into a JS array: a handle scope and a string addref per push, against an array the collector traced, grew, and retained until the end. For a string template every piece is a span of the subject or of the template, both already rooted, and no user code runs between the first match and the last — so nothing can observe an incremental build. Those records are now 12 native bytes, charged to the same external-byte budget as the span list and traced by nobody.
n=200,000, "[$&]" before after Node
peak RSS 1,870 MB 108 MB 265 MB
wall 45,230 ms 3,295 ms 429 ms

At n=50,000: 545 MB / 5,915 ms → 74 MB / 846 ms. So the template path goes from about 7× Node's memory to under half of it, and from 105× its wall to 7.7×. Outputs are identical on every row.

The callback path is untouched by design and stays at RSS parity with Node (161 → 164 MB, 1,884 → 1,904 ms — within noise). A callback's replacement is a string user code produced, so its pieces keep the traced list. This is not the regex-replace-callback rows of #10164/#10165, which use a callback and are unaffected.

A latent hazard found during review, and fixed here

The first head shipped a comment on new_native claiming that a mixed caller "still produces correct output, just without the saving". That is false, and the sabotage proved it: walk emits every native record before any list entry, so a Pieces holding both loses the interleaving — every gap lands before every replacement. Forcing native on the callback path produced reordered bytes (a doubled space where two pieces met, a missing one between records) with no panic and no error.

The shipped code never mixes them — native is chosen only when a template is present, and that path never calls whole — but the invariant lived in prose, and the prose was wrong. append and whole now refuse a native backing, and walk asserts the two are never both populated, so a mixed caller fails where the mistake is rather than producing wrong bytes at finish. The comment now says what is true.

Both sabotage directions were run, not reasoned, under --release:

force Pieces::new         -> "a string template must record its pieces natively", left 0 right 1
force Pieces::new_native  -> RangeError: Regular expression execution failed, exit 1

The second is worth stating precisely: the guard has a debug_assert! and an EngineError::InvalidSpan arm, and debug_assert! is compiled out of [profile.release] — which is what ships, and which [profile.perry-dev] also inherits. So the release Err arm is the load-bearing half; the assertion alone would have enforced the invariant only in a configuration nobody ships. ([profile.gcaudit] exists in Cargo.toml for the other direction: release codegen with debug-assertions = true.)

The original bug was in #10225. Its acceptance evidence was throughput on short subjects and never measured retention at scale, which is how 1.8 GB of RSS got past review. The general form is worth keeping: a witness has to assert its subject is live on the axis that can regress, not only on the axis it is expected to improve.

Validation

Validated head e2cbb28e55. Five-package release build pinned and hash-verified, and re-verified after the gap run.

  • Crate suites: codegen 1571, runtime 3985, stdlib 139, hir 433, transform 137, cli 1139 — all green except main's one known runtime failure. Runtime rises 3984 → 3985, which is the new native-pieces test being registered and actually running.
  • All nine preflight gates pass. The holders gate is worth naming: this PR updates scripts/gc_runtime_root_holders.json itself, and the gate passing on the merged tree confirms the pin is right against current main, not just against the PR's branch.
  • Gap: filters replace, regex, regexp, string, template, unicodeall six clean, with replace and template the two that directly exercise the changed path. The only red anywhere is test_issue58_object_string, listed verbatim in run_parity_tests.sh's SKIP_TESTS on main.

unexplained_regressions={} — no fixture required A/B attribution, which is the first fully clean sweep in this run of trains.

Before merging, the pushed head and unchanged main are checked again. After merging, the rewritten commits are checked for preserved authorship and the main tree must match the validated train exactly.

Summary by CodeRabbit

  • Bug Fixes

    • Improved String.prototype.replace with string templates to reduce memory usage and execution time for large inputs.
    • Replacement output no longer retains unnecessary memory for each generated piece.
    • For a 2.2 MB input, peak memory decreased from approximately 1.87 GB to 108 MB, while processing time dropped from about 45 seconds to 3.3 seconds.
  • Chores

    • Updated the application version to 0.5.1590.

Ralph Küpper added 5 commits September 17, 2026 12:01
…10411)

`str.replace(/re/g, "template")` held roughly a kilobyte of traced heap per
output piece until the whole replacement finished. On a 550 KB subject with
100,000 matches that peaked at 545 MB RSS against Node's 122 MB, and it scaled
with the number of pieces the template produces rather than with the size of
the data: 1,870 MB RSS and 45 s for a 2.2 MB subject.

`Pieces` recorded every piece as three `f64` pushed into a JS array — a handle
scope and a string addref per push, against an array the collector had to
trace and grow. For a string template none of that is needed: every piece is a
span of the subject or of the template, both already rooted by the caller and
outliving the replacement, and no user code runs between the first match and
the last. A callback's pieces still need the list, because the replacement is
a string user code produced.

`Pieces` gains a native backing used only when a template is present. Records
are 12 bytes each, allocated once, charged to the operation's external-byte
budget exactly as the span list is, and traced by nobody. `walk` reads them
without the per-piece pointer comparisons the list needed to identify each
source. The measure-then-copy path, the spec ordering, the span collection and
the template parse are unchanged.

Measured on perrymaster, subject `"ab12 cd345;".repeat(n)`, 12 passes, release
builds from the same base, outputs identical on every row:

  n=50,000   "[$&]"   545 MB / 5,915 ms  ->   74 MB /   846 ms
  n=50,000   "x"      287 MB / 1,791 ms  ->   65 MB /   645 ms
  n=200,000  "[$&]" 1,870 MB / 45,230 ms ->  108 MB / 3,295 ms
  callback (control)  161 MB / 1,884 ms  ->  164 MB / 1,904 ms

Node 26.5.1 on the same rows: 122 MB at n=50,000 and 265 MB / 429 ms at
n=200,000 — so the template path now uses less than half of Node's memory,
where it used seven times as much.
#[cfg(test)] Cell<usize> that records which backing a replacement used; it
holds a count, never a pointer, and is absent from shipped binaries.
…tput

`walk` emits every native record before any list entry, so a `Pieces` holding
both loses the interleaving: on a callback path forced native, each gap lands
before each replacement and the output comes out reordered — a doubled space
where two pieces met and a missing one between records. Silently wrong bytes,
no panic and no error.

The shipped code never mixes them (native is chosen only when a template is
present, and that path never calls `whole`), but the invariant lived in prose,
and the prose was wrong: `new_native`'s comment claimed a mixed caller "still
produces correct output, just without the saving". It does not.

`append` and `whole` now refuse a native backing — a debug assertion naming
the cause, and `EngineError::InvalidSpan` in release — so a mixed caller fails
where the mistake is rather than at `finish`. `walk` asserts the same
invariant. The comment says what is actually true.

Found by perry-b0 running the sabotage direction I had reasoned about rather
than executed: forcing `Pieces::new_native` unconditionally fails on the
callback path's OUTPUT, not on the counter I predicted. Both directions are
now run rather than reasoned:

  force Pieces::new         -> "a string template must record its pieces
                               natively", left 0 right 1
  force Pieces::new_native  -> panics at perex_replace_storage.rs's guard,
                               "a native Pieces cannot take an arbitrary
                               source; walk would reorder the output"
@proggeramlug
proggeramlug merged commit 5030e6e into main Sep 17, 2026
20 of 21 checks passed
@proggeramlug
proggeramlug deleted the train212r branch September 17, 2026 10:54
@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 421b21d3-f722-46f2-b21c-a729eb3c5eca

📥 Commits

Reviewing files that changed from the base of the PR and between 7661bc0 and e2cbb28.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/10412-native-replacement-pieces.md
  • crates/perry-runtime/src/gc/tests/runtime_roots/perex_replace_direct.rs
  • crates/perry-runtime/src/regex.rs
  • crates/perry-runtime/src/regex/perex_replace_direct.rs
  • crates/perry-runtime/src/regex/perex_replace_storage.rs
  • crates/perry-runtime/src/regex/perex_runtime.rs
  • scripts/gc_runtime_root_holders.json

📝 Walkthrough

Walkthrough

Changes

The replacement path now stores string-template spans in native Rust records instead of a traced JavaScript piece list. Callback replacements keep heap-backed storage. Tests verify the backing selection. The workspace version and changelog were updated. A near-search safepoint poll was removed as a probe-only change.

Native replacement pieces

Layer / File(s) Summary
Native span storage
crates/perry-runtime/src/regex/perex_replace_storage.rs
Pieces can store original and template spans in native records. Native capacity uses external-byte accounting, and walk materializes the recorded spans.
Replacement path integration and validation
crates/perry-runtime/src/regex/perex_replace_direct.rs, crates/perry-runtime/src/regex.rs, crates/perry-runtime/src/gc/tests/runtime_roots/perex_replace_direct.rs
String-template replacements select native storage and append subject spans through the new methods. Callback replacements retain the existing storage. Tests verify both paths.
Release metadata
CLAUDE.md, Cargo.toml, changelog.d/10412-native-replacement-pieces.md
Version metadata changes from 0.5.1589 to 0.5.1590. The changelog records the replacement memory and runtime measurements.

GC poll experiment

Layer / File(s) Summary
Near-search safepoint change
crates/perry-runtime/src/regex/perex_runtime.rs, scripts/gc_runtime_root_holders.json
The pre-search poll()? call is removed from find_near_lent. The test-only NATIVE_PIECES holder is registered in the runtime-root holder data.

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant Replace as perex_replace_direct::replace
  participant Pieces as perex_replace_storage::Pieces
  participant Output
  Caller->>Replace: Call replace with string template
  Replace->>Pieces: Create native storage
  Replace->>Pieces: Append original and template spans
  Pieces->>Output: Walk spans and materialize output
  Output-->>Caller: Return replacement string
Loading
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch train212r

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant