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:
- Sum the output length directly from the spans and the parsed template tokens.
- 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 (parse → Vec<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.
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),
mainate6dcb6274d(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 RSSfrom/usr/bin/time -v:"[$&]""x"(m) => "[" + m + "]"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_livefromPERRY_GC_DIAG, same workload:At n=200,000 that is 121× Node's wall (413 ms) on a 2.2 MB string.
VmPeakreaches 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::replacecollects every match span natively first — that part is correct and required, sinceGetSubstitutionmust not observe a partial result. It then builds the output throughperex_replace_storage::Pieces, which records three f64 values per piece (boxed(source),start,end) in a JS array viaList::push, and retains the whole list untilwalk/finishmaterializes it.Per piece that is: three
js_array_push_f64calls, each runningjs_string_addref_if_heap_stringon the boxed source and allocating aRuntimeHandleScope, 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_subphase18.1 %,gc_malloc_header_is_tracked16.6 %, remembered-set root marking 8.5 %,trace_heap_rewrite_slots8.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:
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 (parse→Vec<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:
/[0-9]+/g/(?<d>[0-9]+)/gReproducer
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.