From 74a97c49c60c654a9e4a9c19ebbea1f2ce2c5aa2 Mon Sep 17 00:00:00 2001 From: Maxime Boucher Date: Thu, 11 Jun 2026 22:59:13 -0700 Subject: [PATCH 1/5] test: add benchmarks for glob cache performance Add Issue #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. --- checksum_benchmark_test.go | 155 +++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 checksum_benchmark_test.go diff --git a/checksum_benchmark_test.go b/checksum_benchmark_test.go new file mode 100644 index 0000000000..b08ac36246 --- /dev/null +++ b/checksum_benchmark_test.go @@ -0,0 +1,155 @@ +package task_test + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3" +) + +const ( + issue2853ManySmallYAMLFileCount = 20_000 + issue2853SmallYAMLFileSize = 5 + issue2853FewLargeYAMLFileCount = 4 + issue2853LargeYAMLFileSize = 128 * 1024 * 1024 +) + +func BenchmarkIssue2853ManySmallSparseYAMLFiles(b *testing.B) { + dir := b.TempDir() + createIssue2853Fixture(b, dir, issue2853ManySmallYAMLFileCount, issue2853SmallYAMLFileSize) + + benchmarkIssue2853Modes(b, dir, issue2853ManySmallYAMLFileCount, issue2853SmallYAMLFileSize) +} + +func BenchmarkIssue2853FewLargeSparseYAMLFiles(b *testing.B) { + dir := b.TempDir() + createIssue2853Fixture(b, dir, issue2853FewLargeYAMLFileCount, issue2853LargeYAMLFileSize) + + benchmarkIssue2853Modes(b, dir, issue2853FewLargeYAMLFileCount, issue2853LargeYAMLFileSize) +} + +func benchmarkIssue2853Modes(b *testing.B, dir string, fileCount int, fileSize int64) { + b.Helper() + + for _, mode := range []struct { + name string + task string + expectCache bool + }{ + {name: "checksum", task: "checksum-yaml", expectCache: true}, + {name: "timestamp", task: "timestamp-yaml", expectCache: true}, + {name: "none", task: "uncached-yaml"}, + } { + b.Run(mode.name, func(b *testing.B) { + benchmarkIssue2853Task(b, dir, mode.task, mode.expectCache, fileCount, fileSize) + }) + } +} + +func benchmarkIssue2853Task( + b *testing.B, + dir string, + taskName string, + expectCache bool, + fileCount int, + fileSize int64, +) { + b.Helper() + + tempDir := task.TempDir{ + Remote: filepath.Join(dir, ".task"), + Fingerprint: filepath.Join(dir, ".task"), + } + + if expectCache { + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(io.Discard), + task.WithStderr(io.Discard), + task.WithTempDir(tempDir), + ) + require.NoError(b, e.Setup()) + require.NoError(b, e.Run(b.Context(), &task.Call{Task: taskName})) + } + + b.ReportAllocs() + sourceBytes := int64(fileCount) * fileSize + if expectCache { + b.SetBytes(sourceBytes) + } + b.ResetTimer() + for range b.N { + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithTempDir(tempDir), + ) + require.NoError(b, e.Setup()) + require.NoError(b, e.Run(b.Context(), &task.Call{Task: taskName})) + if expectCache { + require.Contains(b, buff.String(), fmt.Sprintf(`Task "%s" is up to date`, taskName)) + } + } + if expectCache { + b.ReportMetric(float64(fileCount), "source_files/op") + b.ReportMetric(float64(sourceBytes)/(1024*1024), "source_MiB/op") + } +} + +func createIssue2853Fixture(tb testing.TB, dir string, fileCount int, fileSize int64) { + tb.Helper() + + taskfile := `version: '3' + +tasks: + checksum-yaml: + sources: + - path/to/folder/**/*.yaml + generates: + - out/checksum.txt + cmds: + - printf ok > out/checksum.txt + + timestamp-yaml: + method: timestamp + sources: + - path/to/folder/**/*.yaml + generates: + - out/timestamp.txt + cmds: + - printf ok > out/timestamp.txt + + uncached-yaml: + method: none + cmds: + - printf ok > out/uncached.txt +` + require.NoError(tb, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o644)) + require.NoError(tb, os.MkdirAll(filepath.Join(dir, "out"), 0o755)) + + for i := 1; i <= fileCount; i++ { + subdir := filepath.Join(dir, "path", "to", "folder", fmt.Sprintf("%04d", i/100)) + require.NoError(tb, os.MkdirAll(subdir, 0o755)) + name := filepath.Join(subdir, fmt.Sprintf("file-%05d.yaml", i)) + createSparseFile(tb, name, fileSize) + } +} + +func createSparseFile(tb testing.TB, name string, size int64) { + tb.Helper() + + file, err := os.OpenFile(name, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + require.NoError(tb, err) + defer func() { + require.NoError(tb, file.Close()) + }() + require.NoError(tb, file.Truncate(size)) +} From 295fea2452e7dea1f72c961ffe307806f1fe3697 Mon Sep 17 00:00:00 2001 From: Maxime Boucher Date: Fri, 12 Jun 2026 20:01:12 -0700 Subject: [PATCH 2/5] test: gate filesystem benchmarks behind fsbench tag --- checksum_benchmark_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/checksum_benchmark_test.go b/checksum_benchmark_test.go index b08ac36246..3688cad85b 100644 --- a/checksum_benchmark_test.go +++ b/checksum_benchmark_test.go @@ -1,3 +1,6 @@ +//go:build fsbench +// +build fsbench + package task_test import ( From ec19102705c39702b3d0831fcecc67ed8ac54a76 Mon Sep 17 00:00:00 2001 From: Maxime Boucher Date: Fri, 12 Jun 2026 20:02:22 -0700 Subject: [PATCH 3/5] test: add native mtime benchmark reference Add an OS-native mtime reference point for the Issue #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. --- checksum_benchmark_test.go | 56 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/checksum_benchmark_test.go b/checksum_benchmark_test.go index 3688cad85b..b6d843bb0e 100644 --- a/checksum_benchmark_test.go +++ b/checksum_benchmark_test.go @@ -7,9 +7,11 @@ import ( "bytes" "fmt" "io" + "io/fs" "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/require" @@ -44,12 +46,18 @@ func benchmarkIssue2853Modes(b *testing.B, dir string, fileCount int, fileSize i name string task string expectCache bool + nativeMTime bool }{ {name: "checksum", task: "checksum-yaml", expectCache: true}, {name: "timestamp", task: "timestamp-yaml", expectCache: true}, + {name: "native-mtime", nativeMTime: true}, {name: "none", task: "uncached-yaml"}, } { b.Run(mode.name, func(b *testing.B) { + if mode.nativeMTime { + benchmarkIssue2853NativeMTime(b, dir, fileCount, fileSize) + return + } benchmarkIssue2853Task(b, dir, mode.task, mode.expectCache, fileCount, fileSize) }) } @@ -107,6 +115,54 @@ func benchmarkIssue2853Task( } } +func benchmarkIssue2853NativeMTime(b *testing.B, dir string, fileCount int, fileSize int64) { + b.Helper() + + output := filepath.Join(dir, "out", "native-mtime.txt") + require.NoError(b, os.WriteFile(output, []byte("ok"), 0o644)) + outputTime := time.Now().Add(time.Second) + require.NoError(b, os.Chtimes(output, outputTime, outputTime)) + + sourceRoot := filepath.Join(dir, "path", "to", "folder") + sourceBytes := int64(fileCount) * fileSize + + b.ReportAllocs() + b.SetBytes(sourceBytes) + b.ResetTimer() + for range b.N { + outputInfo, err := os.Stat(output) + require.NoError(b, err) + + upToDate, err := nativeMTimeUpToDate(sourceRoot, outputInfo.ModTime()) + require.NoError(b, err) + require.True(b, upToDate) + } + b.ReportMetric(float64(fileCount), "source_files/op") + b.ReportMetric(float64(sourceBytes)/(1024*1024), "source_MiB/op") +} + +func nativeMTimeUpToDate(sourceRoot string, outputTime time.Time) (bool, error) { + upToDate := true + err := filepath.WalkDir(sourceRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || filepath.Ext(path) != ".yaml" { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + if info.ModTime().After(outputTime) { + upToDate = false + return fs.SkipAll + } + return nil + }) + return upToDate, err +} + func createIssue2853Fixture(tb testing.TB, dir string, fileCount int, fileSize int64) { tb.Helper() From a990afca163be7d6b2814ce8645b2c7fb6e7e310 Mon Sep 17 00:00:00 2001 From: Maxime Boucher Date: Wed, 17 Jun 2026 22:25:04 -0700 Subject: [PATCH 4/5] test: simplify filesystem benchmark names 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. --- checksum_benchmark_test.go | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/checksum_benchmark_test.go b/checksum_benchmark_test.go index b6d843bb0e..7677f7fa32 100644 --- a/checksum_benchmark_test.go +++ b/checksum_benchmark_test.go @@ -19,27 +19,27 @@ import ( ) const ( - issue2853ManySmallYAMLFileCount = 20_000 - issue2853SmallYAMLFileSize = 5 - issue2853FewLargeYAMLFileCount = 4 - issue2853LargeYAMLFileSize = 128 * 1024 * 1024 + manySmallFileCount = 20_000 + smallFileSize = 5 + fewLargeFileCount = 4 + largeFileSize = 128 * 1024 * 1024 ) -func BenchmarkIssue2853ManySmallSparseYAMLFiles(b *testing.B) { +func BenchmarkManySmallFiles(b *testing.B) { dir := b.TempDir() - createIssue2853Fixture(b, dir, issue2853ManySmallYAMLFileCount, issue2853SmallYAMLFileSize) + createBenchmarkFixture(b, dir, manySmallFileCount, smallFileSize) - benchmarkIssue2853Modes(b, dir, issue2853ManySmallYAMLFileCount, issue2853SmallYAMLFileSize) + benchmarkModes(b, dir, manySmallFileCount, smallFileSize) } -func BenchmarkIssue2853FewLargeSparseYAMLFiles(b *testing.B) { +func BenchmarkFewLargeFiles(b *testing.B) { dir := b.TempDir() - createIssue2853Fixture(b, dir, issue2853FewLargeYAMLFileCount, issue2853LargeYAMLFileSize) + createBenchmarkFixture(b, dir, fewLargeFileCount, largeFileSize) - benchmarkIssue2853Modes(b, dir, issue2853FewLargeYAMLFileCount, issue2853LargeYAMLFileSize) + benchmarkModes(b, dir, fewLargeFileCount, largeFileSize) } -func benchmarkIssue2853Modes(b *testing.B, dir string, fileCount int, fileSize int64) { +func benchmarkModes(b *testing.B, dir string, fileCount int, fileSize int64) { b.Helper() for _, mode := range []struct { @@ -55,15 +55,15 @@ func benchmarkIssue2853Modes(b *testing.B, dir string, fileCount int, fileSize i } { b.Run(mode.name, func(b *testing.B) { if mode.nativeMTime { - benchmarkIssue2853NativeMTime(b, dir, fileCount, fileSize) + benchmarkNativeMTime(b, dir, fileCount, fileSize) return } - benchmarkIssue2853Task(b, dir, mode.task, mode.expectCache, fileCount, fileSize) + benchmarkTask(b, dir, mode.task, mode.expectCache, fileCount, fileSize) }) } } -func benchmarkIssue2853Task( +func benchmarkTask( b *testing.B, dir string, taskName string, @@ -115,7 +115,7 @@ func benchmarkIssue2853Task( } } -func benchmarkIssue2853NativeMTime(b *testing.B, dir string, fileCount int, fileSize int64) { +func benchmarkNativeMTime(b *testing.B, dir string, fileCount int, fileSize int64) { b.Helper() output := filepath.Join(dir, "out", "native-mtime.txt") @@ -163,7 +163,7 @@ func nativeMTimeUpToDate(sourceRoot string, outputTime time.Time) (bool, error) return upToDate, err } -func createIssue2853Fixture(tb testing.TB, dir string, fileCount int, fileSize int64) { +func createBenchmarkFixture(tb testing.TB, dir string, fileCount int, fileSize int64) { tb.Helper() taskfile := `version: '3' From bd7b2279854b5d4dfc39df45fc9a738e295af6c4 Mon Sep 17 00:00:00 2001 From: Maxime Boucher Date: Wed, 17 Jun 2026 22:40:45 -0700 Subject: [PATCH 5/5] perf: avoid eager fingerprint variable evaluation Only compute CHECKSUM or TIMESTAMP template variables when the raw task references the corresponding variable in commands, deps, preconditions, or status. This keeps the up-to-date source check unchanged while avoiding a duplicate fingerprint pass for tasks that only use sources/generates caching. Many-small benchmark before this branch was about 420 ms/op checksum and 137 ms/op timestamp for 20,000 tiny files. After this change, repeated local runs measured about 144-148 ms/op checksum and 46-48 ms/op timestamp, with allocs dropping from about 742k to 248k for checksum and from about 562k to 188k for timestamp. Verification: go test ./...; go test -tags fsbench -run '^$' -bench 'BenchmarkManySmallFiles/(checksum|timestamp)$' -benchtime=5x -count=3 -benchmem ./ --- variables.go | 145 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 137 insertions(+), 8 deletions(-) diff --git a/variables.go b/variables.go index c7c6cc8493..9d300d2a9b 100644 --- a/variables.go +++ b/variables.go @@ -5,6 +5,7 @@ import ( "maps" "os" "path/filepath" + "reflect" "strings" "github.com/joho/godotenv" @@ -201,15 +202,17 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err checker = fingerprint.NewChecksumChecker(e.TempDir.Fingerprint, e.Dry) } - value, err := checker.Value(&new) - if err != nil { - return nil, err - } - vars.Set(strings.ToUpper(checker.Kind()), ast.Var{Live: value}) + if taskReferencesFingerprintVar(origTask, checker.Kind()) { + value, err := checker.Value(&new) + if err != nil { + return nil, err + } + vars.Set(strings.ToUpper(checker.Kind()), ast.Var{Live: value}) - // Adding new variables, requires us to refresh the templaters - // cache of the the values manually - cache.ResetCache() + // Adding new variables, requires us to refresh the templaters + // cache of the the values manually + cache.ResetCache() + } } if len(origTask.Cmds) > 0 { @@ -326,6 +329,132 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err return &new, nil } +func taskReferencesFingerprintVar(t *ast.Task, kind string) bool { + name := strings.ToUpper(kind) + + for _, status := range t.Status { + if stringReferencesFingerprintVar(status, name) { + return true + } + } + for _, cmd := range t.Cmds { + if cmdReferencesFingerprintVar(cmd, name) { + return true + } + } + for _, dep := range t.Deps { + if depReferencesFingerprintVar(dep, name) { + return true + } + } + for _, precondition := range t.Preconditions { + if preconditionReferencesFingerprintVar(precondition, name) { + return true + } + } + return false +} + +func cmdReferencesFingerprintVar(cmd *ast.Cmd, name string) bool { + if cmd == nil { + return false + } + return stringReferencesFingerprintVar(cmd.Cmd, name) || + stringReferencesFingerprintVar(cmd.Task, name) || + stringReferencesFingerprintVar(cmd.If, name) || + forReferencesFingerprintVar(cmd.For, name) || + varsReferenceFingerprintVar(cmd.Vars, name) +} + +func depReferencesFingerprintVar(dep *ast.Dep, name string) bool { + if dep == nil { + return false + } + return stringReferencesFingerprintVar(dep.Task, name) || + forReferencesFingerprintVar(dep.For, name) || + varsReferenceFingerprintVar(dep.Vars, name) +} + +func preconditionReferencesFingerprintVar(precondition *ast.Precondition, name string) bool { + if precondition == nil { + return false + } + return stringReferencesFingerprintVar(precondition.Sh, name) || + stringReferencesFingerprintVar(precondition.Msg, name) +} + +func forReferencesFingerprintVar(f *ast.For, name string) bool { + if f == nil { + return false + } + if valueReferencesFingerprintVar(f.List, name) { + return true + } + for _, row := range f.Matrix.All() { + if stringReferencesFingerprintVar(row.Ref, name) || + valueReferencesFingerprintVar(row.Value, name) { + return true + } + } + return false +} + +func varsReferenceFingerprintVar(vars *ast.Vars, name string) bool { + if vars == nil { + return false + } + for _, v := range vars.All() { + if valueReferencesFingerprintVar(v.Value, name) || + valueReferencesFingerprintVar(v.Live, name) || + stringPointerReferencesFingerprintVar(v.Sh, name) || + stringReferencesFingerprintVar(v.Ref, name) || + stringReferencesFingerprintVar(v.Dir, name) { + return true + } + } + return false +} + +func valueReferencesFingerprintVar(value any, name string) bool { + if value == nil { + return false + } + if s, ok := value.(string); ok { + return stringReferencesFingerprintVar(s, name) + } + + rv := reflect.ValueOf(value) + switch rv.Kind() { + case reflect.Pointer, reflect.Interface: + if rv.IsNil() { + return false + } + return valueReferencesFingerprintVar(rv.Elem().Interface(), name) + case reflect.Array, reflect.Slice: + for i := 0; i < rv.Len(); i++ { + if valueReferencesFingerprintVar(rv.Index(i).Interface(), name) { + return true + } + } + case reflect.Map: + for _, key := range rv.MapKeys() { + if valueReferencesFingerprintVar(key.Interface(), name) || + valueReferencesFingerprintVar(rv.MapIndex(key).Interface(), name) { + return true + } + } + } + return false +} + +func stringPointerReferencesFingerprintVar(s *string, name string) bool { + return s != nil && stringReferencesFingerprintVar(*s, name) +} + +func stringReferencesFingerprintVar(s string, name string) bool { + return strings.Contains(s, name) +} + func asAnySlice[T any](slice []T) []any { ret := make([]any, len(slice)) for i, v := range slice {