Skip to content

perf: reduce allocations in string.gsub and pattern matching - #558

Open
pixel2175 wants to merge 1 commit into
yuin:masterfrom
pixel2175:perf
Open

pixel2175 wants to merge 1 commit into
yuin:masterfrom
pixel2175:perf

Conversation

@pixel2175

Copy link
Copy Markdown

While profiling Merodi, I found that strGsubDoReplace accounted for 87% of total allocations (1154 MB). This PR reduces memory usage and CPU time in string.gsub and pattern matching.

Changes

  • Build the gsub result once using strings.Builder.
  • Skip replacement scanning when the replacement contains no %.
  • Cache compiled patterns in pm.Find.
  • Reuse MatchData across failed match attempts.
  • Pass MatchData to recursiveVM instead of returning it.

Results

Benchmarked using the same build before and after:

Metric Before After
Allocated memory 1325 MB 132 MB
Allocated objects 3.9M 1.2M
CPU samples 2.07 s 0.73 s
strGsubDoReplace memory 1154 MB 4.6 MB

@pixel2175

pixel2175 commented Oct 30, 2026 •

Copy link
Copy Markdown
Author
  • using pprof

before:
before

after:
after

@pixel2175

Copy link
Copy Markdown
Author

Measured while building git.pi66.xyz.

Before patching:

Executed in   95.05 secs
usr time     132.05 secs

After patching:

Executed in    9.63 secs
usr time       6.06 secs
sys time       4.16 secs

@kimjooyoon kimjooyoon left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked the lifetime of the new pattern cache with a public Lua-API probe at the exact base/head (two fresh process runs each). The varying-pattern workload retains compiled patterns after every Lua state is closed. Reproducer, raw results and sampled heap profile. This is a bounded retained-heap check; I did not rerun the site benchmarks.

Comment thread pm/pm.go Outdated
}
}
c := &compiled{insts, pat.MustHead, ncap}
patCache.Store(p, c)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please give this process-wide cache a finite size or lifetime, with a varying-pattern regression. Each valid pattern is stored before matching, and LState.Close does not release it. In the linked probe, 25,000 distinct nonmatching literal patterns leave about 42 MiB of live Go heap after closing the states and two GCs, versus about 81 KiB at the base. Two fresh process runs agree. Reusing 20,000 existing patterns plateaus; adding 5,000 new ones grows the retained heap again. A long-running host using changing patterns therefore keeps their compiled graphs for the process lifetime, even when it closes its Lua states.

@pixel2175

Copy link
Copy Markdown
Author

@kimjooyoon
The pattern cache is now limited to 1024 entries to prevent unbounded memory growth. Patterns beyond that limit are still compiled and used normally, but aren't added to the cache and i added TestPatternCacheBounded to test this behavior.

@pixel2175

Copy link
Copy Markdown
Author

it has now become:

Unique patterns Base heap Head heap
1 86.8 KB 88.7 KB
1,000 87.0 KB 1.85 MB
10,000 87.1 KB 1.89 MB
20,000 87.2 KB 1.89 MB
Repeat 20,000 87.4 KB 1.89 MB
25,000 87.7 KB 1.89 MB

* build the gsub result once using strings.Builder instead of copying the entire string for each match
* cache compiled patterns in pm
* reuse MatchData across failed match attempts
* remove the unused MatchData return value from recursiveVM
* skip the replacement scanner when the replacement contains no `%`
@pixel2175

Copy link
Copy Markdown
Author

@kimjooyoon My previous changes were stupid
now, instead of saving them across the whole Go process, they live in each LState, which makes it even better

New results:

Unique patterns Base heap Head heap
1 84.6 KB 84.8 KB
1,000 84.7 KB 85.0 KB
10,000 84.9 KB 85.3 KB
20,000 85.0 KB 85.4 KB
Repeat 20,000 85.1 KB 85.4 KB
25,000 85.5 KB 85.7 KB

@kimjooyoon

Copy link
Copy Markdown

Thanks — this addresses my original process-lifetime retention concern at 5041397b462871280f693b8c145a5972cf75bcb2.

Two fresh runs of the unchanged public-Lua-API probe leave 83,488 / 83,264 bytes of live Go heap after 25,000 distinct patterns, releasing the states and two GCs. The original head’s archived results were 43,997,392 / 44,012,432 bytes.

In an additional single-state probe, this finite serial workload plateaus around 2.07 MB. Close while retaining the state still holds roughly 2.03 MB; dropping the reference releases it. That agrees with cache ownership by the shared Global, including its coroutines.

Source-bound results and additional probe. Local ordinary go test ./... and go test -race ./pm pass. Full -race ./... fails at the unchanged TestLocalVarFree finalizer counter; the same failure reproduces at base 75f4976. The evidence documents the retention scope and does not establish concurrent cache-cap or throughput guarantees.

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.

2 participants