Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

perf(regex): capture-heavy exec is 15.9x Node — GC accounting is as large a share as the matcher

Open
#13,139 2 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
1-2 days
Newbie friendliness
48/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Active
Tech stack
node.js, rust, typescript

Research direction

Start by reading the regex execution path around perex::executor::Vm::trial, atom_scan, run, and the host glue that builds the result array; the issue names these profile symbols but no source files or tests. Reproduce with bigreg.ts using the listed perry compile command, then profile the capture-heavy exec workload. Done means a measured reduction in per-call cost with matching output, while isolating which reported subsystem changed.

Written by the indexing model from the issue text.

Description

What happened

RegExp.prototype.exec on a capture-heavy pattern — 18 groups, 42 match registers — costs 40,983 instructions per call against Node 26.5.1's 2,576 — 15.9×, at a fixed 44-character subject.

Found while verifying #11549, which this is not. That issue is fixed: external_side=0 throughout and zero OldReclaim triggers over 300,000 calls, and its lent-cell bound is now 1024 registers, so this workload takes the lent path (find_near_lent is in the profile) and pays no owned-scratch allocation at all. What remains is the per-call cost of that path.

Reproduction

bigreg.ts — a dotenv-shaped line regex, 18 capture groups / 42 registers, with a 64-string rotating subject pool so nothing is loop-invariant:

const re = /^(\s*)(?:(export)\s+)?([\w.-]+)(\s*)=(\s*)(?:(')([^']*)(')|(")([^"]*)(")|(`)([^`]*)(`)|([^#\r\n]*))(\s*)(?:#(.*))?$/;
const lines: string[] = [];
for (let i = 0; i < 64; i++) lines.push('  export KEY_' + i + ' = "value' + i + '" # note');
let h = 0;
for (let k = 0; k < 300000; k++) { const m = re.exec(lines[k & 63]); if (m !== null) h = (h + m.length + (m[3] ? m[3].length : 0)) | 0; }
console.log(h);

A control binary differing only in the exec call is subtracted, so the figures are the call alone. Both runtimes print 7153120.

perry compile bigreg.ts --no-auto-optimize -o bigreg && ./bigreg

Measured on f7725820a, Linux x86_64, perrymaster, against Node 26.5.1. Peak RSS is 16.0 MB against Node's 53.3 MB, so memory is not the problem here.

Where the instructions go

perf record -F 2000 on a --debug-symbols build, aggregated by subsystem:

subsystem share
regex engine (perex::executor) 34.9%
allocation pacing 13.9%
GC, other 12.4%
GC trigger evaluation 8.5%
object model 7.5%
regex host glue 7.3%
result array 7.3%
string/mem 1.9%
unattributed 6.2%

GC-related work totals ~34.8% — the same share as the matcher itself. The top symbols:

 9.76%  <perex::executor::Vm>::trial::<true>
 6.60%  gc::allocation_pacing::sample_arena
 6.28%  <perex::executor::Vm>::atom_scan
 5.55%  <perex::executor::Vm>::run
 4.63%  gc::policy::gc_budgeted_due_trigger_probe
 2.95%  arena::inline::sync_inline_arena_state
 2.82%  array::header_gc_slots::store_array_slot
 2.65%  <perex::executor::Vm>::byte_run
 2.63%  gc::allocation_pacing::note_allocation
 2.63%  gc::barrier_store::runtime_store_jsvalue_slot

Three separable owners

  • Allocation pacing, ~13.9% (sample_arena 6.60%, sync_inline_arena_state 2.95%, note_allocation 2.63%). Per-allocation instrumentation, and this call allocates a result array plus 42 capture slots, so it pays it many times. Not regex-specific — any allocation-heavy call pays it — but this workload makes it measurable.
  • GC trigger evaluation, 8.5% (gc_budgeted_due_trigger_probe 4.63%). The residue of the once-per-gc_malloc ladder; a probe now exists, and it is still 8.5% here.
  • The matcher, 34.9%, and the regex host boundary, 14.6% (host glue plus result array). The second is the regex lane's; the first is the engine's. 18 groups means the result array carries 19 entries and the captures are copied out per call, so some of that boundary cost scales with the group count rather than being fixed.

What this is not

Not #11549 (fixed, verified). Not #10165 (quadratic scaling — this is a flat per-call cost at a fixed subject length). Not #10518's flags Get, which exec does not perform.

Not established

  • How much of this is the capture count rather than the pattern's complexity. A one-group pattern on the same subject would separate them, and I have not run that.
  • Whether the engine's 34.9% is reducible, which is a question for the engine rather than for Perry.
Dominant language
Rust
Stars
4.9k
Forks
166
Avg merge
1h 52m
Merged PRs (30d)
913

Getting set up

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from PerryTS/perry

All issues in PerryTS/perry

Similar issues

More Rust issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.