Skip to content

perf: add fast path for simple recursive globs#2884

Draft
Napolitain wants to merge 5 commits into
go-task:mainfrom
Napolitain:issue-2853-simple-glob-fast-path
Draft

perf: add fast path for simple recursive globs#2884
Napolitain wants to merge 5 commits into
go-task:mainfrom
Napolitain:issue-2853-simple-glob-fast-path

Conversation

@Napolitain

@Napolitain Napolitain commented Jun 18, 2026

Copy link
Copy Markdown

This is a stacked optimization PR on top of the filesystem benchmark work in #2881. Please review or merge #2881 first; this branch intentionally depends on those benchmarks so the performance impact can be evaluated with the same many-small and few-large fixtures.

This is independent from #2883.

This PR optimizes expansion for simple recursive source globs like path/to/folder/**/*.yaml.

The fast path only handles narrow literal-root recursive globs. More complex shell-style patterns continue to use the existing expander, and symlinked directories fall back to the existing path to preserve current behavior.

BenchmarkManySmallFiles/checksum-28                    3         389359224 ns/op           0.26 MB/s             0.09537 source_MiB/op     20000 source_files/op      2004835176 B/op   491402 allocs/op
BenchmarkManySmallFiles/timestamp-28                   3          83254561 ns/op           1.20 MB/s             0.09537 source_MiB/op     20000 source_files/op      42646077 B/op     311394 allocs/op
BenchmarkManySmallFiles/native-mtime-28                3          18549550 ns/op           5.39 MB/s             0.09537 source_MiB/op     20000 source_files/op      11558392 B/op     123036 allocs/op
BenchmarkManySmallFiles/none-28                        3            744356 ns/op         2598650 B/op       3193 allocs/op

BenchmarkFewLargeFiles/checksum-28                     3          60481191 ns/op        8876.66 MB/s           512.0 source_MiB/op            4.000 source_files/op    1064938 B/op       1551 allocs/op
BenchmarkFewLargeFiles/timestamp-28                    3            113883 ns/op        4714246.05 MB/s        512.0 source_MiB/op            4.000 source_files/op     262730 B/op       1525 allocs/op
BenchmarkFewLargeFiles/native-mtime-28                 3             11552 ns/op        46475623.60 MB/s               512.0 source_MiB/op             4.000 source_files/op      4701 B/op         44 allocs/op
BenchmarkFewLargeFiles/none-28                         3           1048155 ns/op         2573429 B/op       3185 allocs/op

Add Issue go-task#2853 benchmarks comparing checksum, timestamp, and uncached tasks across many-small and few-large sparse YAML source sets.

Baseline on Intel i7-14700K, go test -run '^$' -bench 'BenchmarkIssue2853.*SparseYAMLFiles' -benchtime=3x -count=3 ./

Many small sparse YAML files (20,000 x 5 bytes): checksum 440-451 ms/op, timestamp 140-148 ms/op, none 1.1-1.3 ms/op.

Few large sparse YAML files (4 x 128 MiB): checksum 60-61 ms/op, timestamp 213-239 us/op, none 1.1-1.3 ms/op.

Sparse files avoid bulk data writes while preserving logical file size for checksum/timestamp comparisons.
Add an OS-native mtime reference point for the Issue go-task#2853 filesystem benchmarks. The reference walks the same sparse YAML source tree with filepath.WalkDir, stats YAML files through DirEntry.Info, and compares mtimes against a generated output file.

The benchmark is available under the fsbench build tag alongside the Task checksum, timestamp, and uncached cases.
Rename the fsbench benchmark entry points from Issue-2853-specific names to BenchmarkManySmallFiles and BenchmarkFewLargeFiles.

The benchmark output is now easier to scan while the PR and commit history still carry the issue context. Helper and constant names were updated to match; benchmark behavior is unchanged.
Recognize simple literal-root recursive glob patterns such as path/to/folder/**/*.yaml and expand them with filepath.WalkDir plus filepath.Match. Patterns with complex shell features continue to use the existing mvdan shell expander, and symlinked directories explicitly fall back so existing recursive symlink behavior is preserved.

Many-small benchmark before this branch was about 420-450 ms/op checksum and 137 ms/op timestamp for 20,000 tiny files. After this change, repeated local runs measured about 386-391 ms/op checksum and 88-89 ms/op timestamp, with timestamp allocs dropping to about 311k/op.

Verification: go test ./...; go test -tags fsbench -run '^$' -bench 'BenchmarkManySmallFiles/(checksum|timestamp)$' -benchtime=5x -count=3 -benchmem ./
return nil, false, nil
}

results := make(map[string]bool)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Possibly use a slice rather than map.

var results []string
....
matched, _ := filepath.Match(namePattern, d.Name()) // d.Name() is faster than filepath.Base(path)
if !matched {
return nil
}
results = append(results, path)
return nil
...
return results, true, nil

@trulede

trulede commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

About twice as fast, right? Or do I read the results incorrectly.

@trulede

trulede commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

I wondered why Make is so fast ... and after some lazy prompting ... got some kind of idea.

Basically, cache the globing on the first run, and then do os.Stat calls against that cache for subsequent calls. Could be an idea?

package main

import (
	"encoding/json"
	"errors"
	"os"
)

// FileIndex stores the absolute path and the last known modification time
type FileIndex map[string]int64

// LoadIndex reads the cached file metadata from disk
func LoadIndex(cachePath string) (FileIndex, error) {
	data, err := os.ReadFile(cachePath)
	if errors.Is(err, os.ErrNotExist) {
		return make(FileIndex), nil
	}
	if err != nil {
		return nil, err
	}

	var index FileIndex
	if err := json.Unmarshal(data, &index); err != nil {
		return nil, err
	}
	return index, nil
}

// SaveIndex persists the updated file metadata to disk
func SaveIndex(cachePath string, index FileIndex) error {
	data, err := json.MarshalIndent(index, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(cachePath, data, 0644)
}

// CheckChanges fast-scans the explicit list, leveraging the OS VFS cache
func CheckChanges(index FileIndex) (changed []string, missing []string) {
	for path, cachedMtime := range index {
		info, err := os.Stat(path)
		if err != nil {
			if errors.Is(err, os.ErrNotExist) {
				missing = append(missing, path)
			}
			continue
		}

		// Direct timestamp verification
		if info.ModTime().UnixNano() != cachedMtime {
			changed = append(changed, path)
		}
	}
	return changed, missing
}

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