You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
perf/gc: regex scratch counted as external GC pressure forces full collections; growing it wins −16..−31% instr but +13..+28% RSS #11549
The regex slow path reports its per-call scratch buffers to the GC as external allocations. For patterns needing more than 32 match registers (for example dotenv's line regex, which has 42), that phantom external pressure keeps hot loops in frequent full collections.
Measured: letting the per-thread match scratch grow beyond 32 registers gives dotenv/parse −31% and moment/parse_format −16% instructions. But peak RSS rises +27.6% / +13.4%, because once the phantom pressure is gone the young generation grows to its cap before collecting. So it was not shipped, under the "minimize RSS AND keep best compute" rule.
We want both wins. Directions:
Stop counting short-lived, immediately-freed scratch as external GC pressure, or count it as transient so it doesn't drive full collections.
Separately, look at the young-generation pacing that lets RSS climb to the cap when allocation pressure is low. An RSS-aware nursery cap or earlier minor GC would keep RSS flat with the faster regex path.
Measurements are on branch wip/perf-regex-matcher and in #11543's description.
Progress: #11634 landed. A copying minor's fixed cost drops ~87% (alloc loop: 753k -> 98k instructions per minor) via a young-only intern-table log, skipping empty array-tail tables, a young-only closure-metadata prune and skipping pinned string caches on minors; each skip has a completeness check and a sabotage test. No default changed. Leaving this open for the remaining per-minor cost.
Status: the regex scratch is no longer counted as GC pressure. That landed with #11645 (merged 9e29f59, carrying #11612), together with #11634, #11668 and #11676 (the minor's fixed cost and per-object trace cost) and survival-aware nursery pacing. The regressions the owner accepted are tracked in #11699. Keeping this open until the post-fix package matrix confirms the dotenv/moment numbers.
Both directions landed. The accounting change is in regex/perex_memory.rs, which now cites this issue in its header:
This scratch is NOT reported to the collector as external side bytes (#11549). Everything here is freed by the operation that allocated it, when that operation returns or unwinds; no collection can ever reclaim a byte of it, and no collection is needed for it to be released.
Direction 2 landed separately as 9e29f59d4 (survival-aware nursery pacing, 4 MB floor on the influx ladder, includes #11612).
Measured on the shape this issue names
A pattern with 42 match registers — more than the 32 the lent cell holds, so every call takes the owned scratch path — exec in a hot loop, 300,000 calls, on f7725820a:
trigger kinds over the whole run
nursery ×43, ArenaBytes ×43 — no OldReclaim at all
external_side
0, every trigger line
copying minors
87 (one per ~3,450 calls)
The symptom this issue describes — per-call buffer releases accumulating in GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL and pacing the loop "into a budgeted full mark-sweep every few hundred calls" — produced OldReclaim triggers. There are none.
The RSS objection no longer applies
This was held back because removing the phantom pressure cost +27.6% / +13.4% peak RSS. On that 42-register workload now:
peak RSS
perry
16.0 MB
node 26.5.1
53.3 MB
Perry is 3.3× under node. Even the full +27.6% from the original measurement would leave it at ~20 MB, still less than half of node's. Whatever the "minimize RSS AND keep best compute" rule was protecting, it is not at risk at this size.
What is not fixed, and is a different issue
Instructions per call on that workload are still 40,983 against node's 2,576 — 15.9× (control-subtracted, 300k calls, outputs identical). That is the owned-scratch path's own cost, not the GC pacing this issue is about, and it is the largest per-call regex ratio I can currently measure. Worth its own issue rather than keeping this one open for it.
Activity
test(regex): operation scratch and lent-cell searches report no exter…
perf(gc): young-only prune for built-in closure metadata on a minor
perf(gc): a minor skips the small-int / ASCII-char string caches
perf(gc): young-log the intern table, skip unarmed array-tail tables …
perf(gc): young-only prune for built-in closure metadata on a minor
perf(gc): a minor skips the small-int / ASCII-char string caches
proggeramlug commentedon Sep 28, 2026
Progress: #11634 landed. A copying minor's fixed cost drops ~87% (alloc loop: 753k -> 98k instructions per minor) via a young-only intern-table log, skipping empty array-tail tables, a young-only closure-metadata prune and skipping pinned string caches on minors; each skip has a completeness check and a sabotage test. No default changed. Leaving this open for the remaining per-minor cost.
perf(regex): stop charging operation scratch to GC external pressure;…
perf(gc): survival-aware nursery pacing: the ladder starts at a quart…
perf(regex): stop charging operation scratch to GC external pressure;…
perf(gc): survival-aware nursery pacing: the ladder starts at a quart…
perf(regex): stop charging operation scratch to GC external pressure;…
perf(gc): survival-aware nursery pacing: the ladder starts at a quart…
perf(gc): copying drain scans ordinary objects through a straight-lin…
perf(gc): raw words consult the mark memo first; skip the carrier gen…
6 remaining items
perf(gc): copying drain instantiates the slot walk for its visitor; p…
perf(gc): copying drain scans ordinary objects through a straight-lin…
perf(gc): raw words consult the mark memo first; skip the carrier gen…
perf(gc): the plain-object scan takes the residual-prototype edge and…
perf(regex): stop charging operation scratch to GC external pressure;…
perf(gc): survival-aware nursery pacing: the ladder starts at a quart…
perf(gc): cut the copying minor's per-live-object trace cost (straigh…
perf(regex): stop charging operation scratch to GC external pressure;…
perf(gc): survival-aware nursery pacing: the ladder starts at a quart…
perf(regex): stop charging operation scratch to GC external pressure;…
perf(gc): survival-aware nursery pacing: the ladder starts at a quart…
perf(gc): survival-aware nursery pacing, 4 MB floor on the existing i…
proggeramlug commentedon Sep 30, 2026
Status: the regex scratch is no longer counted as GC pressure. That landed with #11645 (merged 9e29f59, carrying #11612), together with #11634, #11668 and #11676 (the minor's fixed cost and per-object trace cost) and survival-aware nursery pacing. The regressions the owner accepted are tracked in #11699. Keeping this open until the post-fix package matrix confirms the dotenv/moment numbers.
proggeramlug commentedon Oct 23, 2026
Verified fixed on current main — suggest closing
Both directions landed. The accounting change is in
regex/perex_memory.rs, which now cites this issue in its header:Direction 2 landed separately as
9e29f59d4(survival-aware nursery pacing, 4 MB floor on the influx ladder, includes #11612).Measured on the shape this issue names
A pattern with 42 match registers — more than the 32 the lent cell holds, so every call takes the owned scratch path —
execin a hot loop, 300,000 calls, onf7725820a:nursery×43,ArenaBytes×43 — noOldReclaimat allexternal_sideThe symptom this issue describes — per-call buffer releases accumulating in
GC_EXTERNAL_SIDE_DRAINED_SINCE_FULLand pacing the loop "into a budgeted full mark-sweep every few hundred calls" — producedOldReclaimtriggers. There are none.The RSS objection no longer applies
This was held back because removing the phantom pressure cost +27.6% / +13.4% peak RSS. On that 42-register workload now:
Perry is 3.3× under node. Even the full +27.6% from the original measurement would leave it at ~20 MB, still less than half of node's. Whatever the "minimize RSS AND keep best compute" rule was protecting, it is not at risk at this size.
What is not fixed, and is a different issue
Instructions per call on that workload are still 40,983 against node's 2,576 — 15.9× (control-subtracted, 300k calls, outputs identical). That is the owned-scratch path's own cost, not the GC pacing this issue is about, and it is the largest per-call regex ratio I can currently measure. Worth its own issue rather than keeping this one open for it.