Skip to content

Avoid per-match allocations in matchPattern and walkRecursive#11

Merged
andrew merged 3 commits intomainfrom
match-perf
May 7, 2026
Merged

Avoid per-match allocations in matchPattern and walkRecursive#11
andrew merged 3 commits intomainfrom
match-perf

Conversation

@andrew
Copy link
Copy Markdown
Contributor

@andrew andrew commented May 6, 2026

Three small allocation reductions on the match path, raised by @seh while reviewing #10.

matchPattern was calling strings.Split(p.prefix, "/") on every invocation for patterns that came from a nested .gitignore. The prefix never changes after compilation, so compilePattern now splits it once and stores the segments on the pattern struct.

walkRecursive was building a match path with a trailing / for directory entries and calling Match, which then strips the slash off again. It now calls MatchPath with entry.IsDir() directly and skips the concatenation.

walkRecursive was also calling os.Stat on each directory's .gitignore before AddFromFile, but AddFromFile already handles a missing file by returning early on the os.ReadFile error. Dropping the guard saves a syscall per directory walked.

Added BenchmarkMatchNestedPatterns and BenchmarkWalk to cover these paths. On an M1 Pro:

BenchmarkMatchNestedPatterns  6563 ns  560 B  13 allocs  ->  5244 ns  80 B  1 alloc
BenchmarkWalk                 2402 allocs                ->  2282 allocs

The existing match benchmarks also dropped a bit (around 10%) though those have no prefixed patterns so that may partly be noise.

Comment thread gitignore.go
}

if m.Match(matchPath) {
if m.MatchPath(filepath.ToSlash(entryRel), entry.IsDir()) {
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 don't know if this is feasible, but would it be possible to compile the patterns from the gitignore files anticipating the hosting operating system's (or file system's) path separator, such that all of the subsequent match tests don't need to attempt this path separator conversion?

It would be nice if we could do some extra work up front, and then save perhaps many thousands of calls to filepath.ToSlash later—even if those calls mostly terminate with a single byte comparison.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

On anything other than Windows filepath.ToSlash already short-circuits to return path after the Separator == '/' check, so there is nothing to save here on Unix.

On Windows it is a real allocation per entry. Avoiding it is awkward though: the matcher splits the input on / and the public Match/MatchPath contract is slash-separated paths, so compiling patterns to the OS separator would either change that contract or require match to split on both. The narrower fix is to keep rel in slash form inside walkRecursive and only FromSlash when calling fn, which trades one conversion for another in a different spot.

Happy to look at the walkRecursive rework as a follow-up if Windows is a target you care about, but I would rather not widen this PR further.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thank you for the explanation.

In my own program, since I know that I'm not going to use it anything other than Linux and macOS, I'm not bothering with the path separator conversion calls.

Comment thread gitignore.go Outdated
if p.prefix != "" {
prefixSegs := strings.Split(p.prefix, "/")
if len(segs) < len(prefixSegs) {
if len(p.prefix) > 0 {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This may just disappear in the compiler output, but consider saving this length for several subsequent uses in this block.

Suggested change
if len(p.prefix) > 0 {
if prefixLength := len(p.prefix); prefixLength > 0 {

Then s/len\(p\.prefix\)/prefixLength/g.

Were it not for the assignment to segs at line 354, we could drop this check for a positive length, because all of the statements that follow would still work correctly even when p.prefix is empty. It's wasteful, though, to get to the segs = segs[len(p.prefix):] slicing and assignment statement unnecessarily.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in c4aa225 (went with n rather than prefixLength to keep the lines short).

Comment thread gitignore.go
return false
}
for i, ps := range prefixSegs {
for i, ps := range p.prefix {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This could use slices.Equal, but we then pay the cost of the length comparison again.

if !slices.Equal(p.prefix, segs[:len(p.prefix)]) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, sticking with the explicit loop since it avoids the redundant length check and the extra import.

Copy link
Copy Markdown

@seh seh left a comment

Choose a reason for hiding this comment

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

Thank you for all of this refinement.

Comment thread gitignore.go
}

if m.Match(matchPath) {
if m.MatchPath(filepath.ToSlash(entryRel), entry.IsDir()) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thank you for the explanation.

In my own program, since I know that I'm not going to use it anything other than Linux and macOS, I'm not bothering with the path separator conversion calls.

@andrew andrew merged commit cb3da87 into main May 7, 2026
7 checks passed
@andrew andrew deleted the match-perf branch May 7, 2026 18:27
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