Skip to content

Cache compiled regex patterns instead of recompiling per call - #323

Open
G360-Niek wants to merge 3 commits into
projectdiscovery:mainfrom
guardian360:fix/cache-compiled-regex-patterns
Open

G360-Niek wants to merge 3 commits into
projectdiscovery:mainfrom
guardian360:fix/cache-compiled-regex-patterns

Conversation

@G360-Niek

@G360-Niek G360-Niek commented Aug 11, 2026

Copy link
Copy Markdown

Closes #322.

What

regex, regex_all, regex_any and replace_regex call regexp.Compile on every invocation. The pattern is normally a constant in the expression while the subject changes between evaluations, so the same pattern is compiled repeatedly — and compilation is the expensive part of these helpers, depending only on the first argument.

This adds a compileRegex helper backed by a cache keyed on the pattern, and uses it at the four call sites (dsl.go:283, :774, :788, :810).

Why the existing result cache does not cover it

dslFunction.Exec caches results for cacheable functions, but the key hashes the function name and every argument:

functionHash := d.hash(args...)

Whenever the subject differs between calls — the common case for an expression evaluated against changing input — the key differs, the cache misses, and the pattern is compiled again. The caching is at call granularity; the reusable work is at pattern granularity.

Design notes

  • Backed by a dedicated DefaultRegexCacheSize bound (see Cache sizing below); patterns assembled at runtime cannot grow it without limit.
  • *regexp.Regexp is safe for concurrent use, so one compilation can serve every caller.
  • Behaviour is unchanged, including the error returned for an invalid pattern — an unparseable pattern still fails on every call, since only successful compilations are cached.
  • Eviction is left as the default (Simple), the same as resultCache.

Cache sizing

The first revision bounded the cache by the shared DefaultCacheSize (6144). That is far below the distinct-pattern working set, so it does not actually help the case that motivated the change: the public nuclei-templates HTTP corpus assembles ~53k distinct patterns, with two WordPress fingerprint templates accounting for most of them (wordpress-plugin-detect.yaml: 55,112 calls / 45,748 distinct; wordpress-theme-detect.yaml: 7,521). With 6144 slots against ~53k patterns every entry is evicted before it is reused — the cache never hits and only adds bookkeeping (marginally worse than no cache).

Measured by scanning a local server with those two templates, varying the number of responses:

responses upstream cache @ 6144 cache @ 200k
8 1409.8 MiB 1425.9 MiB 1128.7 MiB
24 3468.6 MiB 1249.4 MiB

Upstream allocation scales with the number of responses; a correctly sized cache stays flat because each pattern is compiled once and reused thereafter.

This revision adds a dedicated, exported DefaultRegexCacheSize (200000) — a generous (~4×) multiple of that ~53k working set:

  • It is a ceiling, not a preallocation: the cache holds only the patterns actually compiled (~130 MiB retained for the ~53k corpus above; a bounded worst case of ~500 MiB only if a caller genuinely compiles 200k distinct patterns).
  • Exported so a caller can size it to its own corpus — lower for memory-tight callers, higher for larger corpora.
  • Still bounded, so patterns built at runtime from input cannot grow it without limit.

Benchmark

Included as regex_bench_test.go: a constant pattern against a unique ~19 KB subject per iteration, so the result cache cannot hit.

ns/op B/op allocs/op
before 392,206 28,182 60
after 387,793 19,219 7

About 9 KB and 53 allocations of library overhead removed per call; the ~19 KB remaining is the benchmark constructing its own subject. Wall clock is unchanged — the saving is allocation and the GC pressure that follows from it, not latency.

Happy to drop the benchmark from the PR if you would rather it lived elsewhere.

Verification

go test -race ./... and golangci-lint run ./... both pass unchanged.

Context

Found while profiling Nuclei, where this single line accounted for 259 GB — 27.5% — of everything one long-running process allocated over 14 hours. Reconfirmed on a current build: ~280 GB, ~26% of all allocation over a ~19h scan. Details and the profile output are in #322.

@G360-Niek

Copy link
Copy Markdown
Author

Marking this a draft: as written it does not achieve the case that motivated it, and I would rather say so here than leave the PR body overstating it.

Testing it in a consumer showed the pattern cache is bounded by DefaultCacheSize (6144) while the real pattern set is far larger — wordpress-plugin-detect.yaml in nuclei-templates alone contains 45,748 distinct patterns. The LRU evicts every entry before it is reused, so the cache never hits and only adds bookkeeping.

Measured by scanning a local server with that template, varying the number of responses:

responses upstream v0.8.20 this patch @ 6144 patch @ 200k entries
8 1409.8 MiB 1425.9 MiB 1128.7 MiB
24 3468.6 MiB 1249.4 MiB

At 6144 it is marginally worse than upstream. Sized to the pattern set it works — upstream's allocation scales with responses while the cached version stays nearly flat — at a cost of roughly 130 MiB retained.

Full detail in #322. I did not want to unilaterally pick a new default for a library-wide cache, so I will rework this once there is a steer on sizing: a separate larger default for patterns, unbounded, or configurable. Happy to take direction, or to close this if you would rather solve it differently.

@G360-Niek
G360-Niek marked this pull request as ready for review August 19, 2026 09:12
@G360-Niek

Copy link
Copy Markdown
Author

Update: revised and taken out of draft.

My earlier note in #322 left this parked because I didn't want to guess at the cache size. Rather than leave it stalled, I've gone with option 3 from that comment — a dedicated, exported default sized to the pattern working set:

  • Added DefaultRegexCacheSize = 200000, separate from DefaultCacheSize. It's ~4× the ~53k distinct-pattern working set the HTTP corpus produces, so patterns compile once and are reused instead of thrashing at 6144 (which, as measured in Regex helpers recompile their pattern on every call #322, was marginally worse than no cache).
  • It's a ceiling, not a preallocation — the cache holds only the patterns actually compiled (~130 MiB retained for the real corpus; bounded ~500 MiB worst case only if a caller genuinely compiles 200k distinct patterns) — and it's exported so a consumer can tune it.
  • Kept the default (Simple) eviction, consistent with resultCache. With the cap above the working set there's no eviction to police, so a separate policy for this one cache wasn't warranted.

The updated PR description carries the sizing table and rationale. The default is a one-line change — happy to go more conservative (e.g. 131072, a ~330 MiB ceiling) if you'd prefer a smaller worst case; it still covers the current corpus with headroom.

regex, regex_all, regex_any and replace_regex compiled their pattern on
every invocation. The pattern is normally a constant in the expression
while the subject changes between evaluations, so the same pattern was
compiled over and over — and compilation is the expensive part of these
helpers, depending only on the first argument.

The existing result cache does not cover this: its key hashes the
function name and every argument, so a differing subject misses the cache
and the pattern is compiled again. The caching is at call granularity
while the reusable work is at pattern granularity.

Compiled patterns now live in a cache keyed on the pattern, bounded by
DefaultCacheSize like resultCache so patterns assembled at runtime cannot
grow it without limit. *regexp.Regexp is safe for concurrent use, so one
compilation serves every caller.

Benchmarked with a constant pattern against a unique ~19KB subject per
iteration, so the result cache cannot hit: 28182 B/op and 60 allocs/op
before, 19219 B/op and 7 allocs/op after — roughly 9KB and 53 allocations
of library overhead removed per call. Wall clock is unchanged; the saving
is allocation and the GC pressure that follows it.
The compiled-regex cache was bounded by DefaultCacheSize (6144), far below
the number of distinct patterns a caller can reference in a single pass. At
that size entries are evicted before they are reused, so the cache never
hits and every call recompiles anyway — marginally worse than no cache.

Add a dedicated DefaultRegexCacheSize (200000, a generous multiple of the
largest working sets we have measured) so distinct patterns compile once and
are reused. It is a ceiling, not a preallocation: memory scales with the
patterns actually compiled, and the value is exported so a caller can size it
to its own use. Left as the default (Simple) eviction, consistent with
resultCache.
`go test -bench . -count=N` calls the benchmark function N times in one
process, and `resultCache` is package-level. A counter starting at zero each
invocation therefore replayed the same subjects, so from the second run onward
every call was served by the result cache: 59 allocs/op on the first run,
5 on the rest, with or without a compiled-pattern cache.

That made the benchmark report the cost of a cache hit rather than of
compiling, and show no difference between a cached and an uncached
implementation — the opposite of what it exists to measure, and visible only if
someone ran it the usual way for benchstat.

Numbering subjects across the process keeps every call a miss. Baseline now
holds 59-60 allocs/op across all runs instead of collapsing to 5.
@G360-Niek
G360-Niek force-pushed the fix/cache-compiled-regex-patterns branch from 7d23ed3 to 402d59e Compare September 14, 2026 07:38
@G360-Niek

Copy link
Copy Markdown
Author

Rebased onto current main (was 7 behind, no conflicts), and pushed a fix to the
benchmark in this PR — details below, since it changes what the numbers say.

No checks have run on this PR yet, which I think means a fork PR needs a
maintainer to approve the workflow run. Happy to do something else instead if
that's not it.

What the change does. regex() and friends call regexp.Compile on every
invocation, so a pattern used throughout a run is recompiled each time. This
compiles once and reuses it (*regexp.Regexp is safe for concurrent use),
bounded by a separate DefaultRegexCacheSize rather than DefaultCacheSize
distinct patterns can far outnumber result-cache keys, so sharing the smaller
bound would evict entries before they are reused.

The benchmark was wrong, and I have corrected it. go test -bench -count=N
calls the benchmark function N times in one process, and resultCache is
package-level. My subject counter restarted at zero each invocation, so from the
second run onward every call hit the result cache — 59 allocs/op on the first
run, 5 on the rest, with or without this change. Anyone running it the normal
way for benchstat would have seen no difference at all. Subjects are now numbered
across the process, so every call is a miss.

With the corrected benchmark (-benchtime 200x -count=5, Apple M4):

ns/op B/op allocs/op
main ~386k–436k ~28,200 59–60
this PR ~374k–434k ~19,250 6–7

So this is an allocation fix, not a latency fix: allocations drop ~88% and
bytes/op ~32%, while wall time is within noise.
The regex match over a ~19KB
subject dominates the time; compilation dominates the allocations. That matches
how we found it — profiling a long scan in our product, cumulative allocations
attributed to compilation in this path reached hundreds of GB. Nothing is
retained, so it is churn rather than a leak, but it is a lot of GC pressure for
work that only needs doing once.

Happy to adjust the bound, the cache choice, or the benchmark shape if you would
prefer any of it done differently.

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.

Regex helpers recompile their pattern on every call

1 participant