Skip to content

perf(regex): replace with a string template holds ~1 KB per output piece — 545 MB RSS on a 550 KB subject, 4.5x Node #10411

Description

@proggeramlug

What happens

String.prototype.replace(/re/g, "template") retains roughly 1 KB of heap per output piece until the whole replacement finishes. On a 550 KB subject with 100,000 matches it peaks at 545 MB RSS against Node's 122 MB, and the cost scales linearly with how many pieces the template produces, not with the size of the data.

The same replacement written as a callback is at RSS parity with Node. That is the surprising part: the fast path added for string templates (#10225) is the expensive one.

Measurements

perrymaster (Linux x86_64), main at e6dcb6274d (v0.5.1587), release from source, --no-auto-optimize, Node 26.5.1. Subject is "ab12 cd345;".repeat(50000) — 550 KB, 100,000 matches — replaced 12 times. max RSS from /usr/bin/time -v:

replacement pieces per match Perry max RSS Node max RSS Perry wall Node wall
"[$&]" 4 545 MB 122 MB 5,950 ms
"x" 2 287 MB 1,781 ms
(m) => "[" + m + "]" 164 MB 144 MB 1,887 ms

Halving the pieces per match halves the excess (545 → 287 MB against a 164 MB callback baseline), which puts the cost at 0.6–1.0 KB per piece for a piece that carries 24 bytes of data.

It scales linearly with subject size, so it gets worse without bound. Peak arena_live from PERRY_GC_DIAG, same workload:

n subject matches peak live heap wall
50,000 550 KB 100k 406 MB 5,950 ms
100,000 1.1 MB 200k 807 MB 18,609 ms
200,000 2.2 MB 400k 1,613 MB 50,171 ms

At n=200,000 that is 121× Node's wall (413 ms) on a 2.2 MB string. VmPeak reaches 1.07 GB at n=50,000, so a subject a few megabytes larger will exhaust memory on a normal machine.

Where it comes from

perex_replace_direct::replace collects every match span natively first — that part is correct and required, since GetSubstitution must not observe a partial result. It then builds the output through perex_replace_storage::Pieces, which records three f64 values per piece (boxed(source), start, end) in a JS array via List::push, and retains the whole list until walk/finish materializes it.

Per piece that is: three js_array_push_f64 calls, each running js_string_addref_if_heap_string on the boxed source and allocating a RuntimeHandleScope, against an array that must be grown and traced as a pointer-bearing object. A profile of the workload is over 60 % collector — RootScanCycleState::step_current_subphase 18.1 %, gc_malloc_header_is_tracked 16.6 %, remembered-set root marking 8.5 %, trace_heap_rewrite_slots 8.1 % — which is what a linearly growing traced live set costs.

Why the callback path is cheaper

It has to materialize each replacement as a JS string anyway (user code produced it), so it does not accumulate a piece list of the same shape. The template path could avoid materializing anything: with a string template, every piece is a span of either the input or the template, both of which are already live and native. Nothing user-visible happens between the first match and the last.

Suggested fix

Two native passes over the collected spans instead of a retained JS piece list:

  1. Sum the output length directly from the spans and the parsed template tokens.
  2. Allocate the output string once and copy each span into it.

That is O(1) extra memory beyond the result, removes every List::push, and removes the traced piece array entirely. The span collection, the template parse (parseVec<Token>) and the spec ordering all stay as they are.

If that is more surgery than wanted, the cheap mitigation is to decline the fast path above a match count — the crossover where the generic path becomes faster is already measured at about 100,000 matches:

n direct fast path /[0-9]+/g generic path /(?<d>[0-9]+)/g
50,000 406 MB, 5,950 ms 60 MB, 9,117 ms
100,000 807 MB, 18,609 ms 120 MB, 20,863 ms

Reproducer

const n = Number(process.argv[process.argv.length - 1]);
const input = "ab12 cd345;".repeat(n);
let h = 0;
for (let i = 0; i < 12; i++) {
  const out = input.replace(/[0-9]+/g, "[$&]");
  h = (h * 31 + out.length) % 1000000007;
}
console.log(h);
perry compile repro.ts --no-auto-optimize -o repro
/usr/bin/time -v ./repro 50000 2>&1 | grep "Maximum resident"
PERRY_GC_DIAG=1 ./repro 50000 2>&1 | grep -E "gc-incremental|gc-time"

Swap the replacement for (m) => "[" + m + "]" for the callback control, and add a named group (/(?<d>[0-9]+)/g) to decline the fast path and get the generic-path control. Output is correct in every arm — this is memory and time, not correctness.

Found while attributing the replace rows of #10164 and #10165. The fast path is mine (#10225); its acceptance evidence covered throughput on short subjects and never measured retention at scale.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions