Skip to content

test: add benchmarks for glob cache performance#2881

Merged
andreynering merged 5 commits into
go-task:mainfrom
Napolitain:issue-2853-glob-benchmarks
Jul 11, 2026
Merged

test: add benchmarks for glob cache performance#2881
andreynering merged 5 commits into
go-task:mainfrom
Napolitain:issue-2853-glob-benchmarks

Conversation

@Napolitain

@Napolitain Napolitain commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

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

In my opinion, it looks like there are too many allocations and there must be inefficiencies in the many-small sources scenario.

  many small / checksum    419 ms   0.24 MB/s      0.095 MiB, 20000 files
  many small / timestamp   144 ms   0.69 MB/s      0.095 MiB, 20000 files
  many small / none        0.71 ms

  few large / checksum    60.4 ms   8883 MB/s      512 MiB, 4 files
  few large / timestamp   0.23 ms   2330118 MB/s   512 MiB, 4 files
  few large / none        0.56 ms

The MB/s is not a IO rate (timestamp doesn't do IO). More like, a throughput comparison.

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.
@Napolitain

Copy link
Copy Markdown
Contributor Author

I suggest this benchmark for tracking speed for many small, and few large globs.

@trulede

trulede commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Would/could you add an OS Native benchmark too, using mtime. As a reference point.

Having the test profile code might also be useful. This function in particular:
https://github.com/go-task/task/blob/main/internal/fingerprint/sources_timestamp.go

Edit: Another point of reference (in addition to mtime) would be to generate a Makefile and run that over the files too.

Comment thread checksum_benchmark_test.go
@trulede

trulede commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

@Napolitain If you want to try your luck and improve the performance, I "Asked AI" to make the code more efficient, and then again to see if the duplicate calls to os.Stat() could be improved. There is not much code there, so profiling or trial and error should find some improvement.

https://github.com/go-task/task/blob/main/internal/fingerprint/sources_timestamp.go

Strategy: globbing improved
package fingerprint

import (
	"os"
	"path/filepath"
	"time"

	"github.com/go-task/task/v3/taskfile/ast"
)

// TimestampChecker checks if any source change compared with the generated files,
// using file modifications timestamps.
type TimestampChecker struct {
	tempDir string
	dry     bool
}

func NewTimestampChecker(tempDir string, dry bool) *TimestampChecker {
	return &TimestampChecker{
		tempDir: tempDir,
		dry:     dry,
	}
}

// IsUpToDate implements the Checker interface
func (checker *TimestampChecker) IsUpToDate(t *ast.Task) (bool, error) {
	if len(t.Sources) == 0 {
		return false, nil
	}

	sources, err := Globs(t.Dir, t.Sources)
	if err != nil {
		return false, nil
	}

	// 1. Evaluate general glob lists immediately to avoid duplicate disk scans
	generates, err := Globs(t.Dir, t.Generates)
	if err != nil {
		return false, nil
	}

	// 2. Optimized Early Exit: If patterns exist but found no files, task must run
	if len(t.Generates) > 0 {
		hasPositivePattern := false
		for _, g := range t.Generates {
			if !g.Negate {
				hasPositivePattern = true
				break
			}
		}
		if hasPositivePattern && len(generates) == 0 {
			return false, nil
		}
	}

	timestampFile := checker.timestampFilePath(t)

	// 3. Check timestamp file existence
	_, err = os.Stat(timestampFile)
	if err == nil {
		generates = append(generates, timestampFile)
	} else {
		// Create the timestamp file for the next execution when it does not exist.
		if !checker.dry {
			if err := os.MkdirAll(filepath.Dir(timestampFile), 0o755); err != nil {
				return false, err
			}
			f, err := os.Create(timestampFile)
			if err != nil {
				return false, err
			}
			f.Close()
		}
	}

	taskTime := time.Now()

	// 4. FIX: Get the MINIMUM (oldest) time of the generates, not the max.
	// If any source is newer than our OLDEST output, the build is stale.
	generateMinTime, err := getMinTime(generates...)
	if err != nil || generateMinTime.IsZero() {
		return false, nil
	}

	// 5. Check if any source files are newer than our oldest generated file (Lazy execution)
	shouldUpdate, err := anyFileNewerThan(sources, generateMinTime)
	if err != nil {
		return false, nil
	}

	// Modify the metadata of the file to the current time.
	if !checker.dry {
		if err := os.Chtimes(timestampFile, taskTime, taskTime); err != nil {
			return false, err
		}
	}

	return !shouldUpdate, nil
}

func (checker *TimestampChecker) Kind() string {
	return "timestamp"
}

// Value implements the Checker Interface
func (checker *TimestampChecker) Value(t *ast.Task) (any, error) {
	sources, err := Globs(t.Dir, t.Sources)
	if err != nil {
		return time.Now(), err
	}

	sourcesMaxTime, err := getMaxTime(sources...)
	if err != nil {
		return time.Now(), err
	}

	if sourcesMaxTime.IsZero() {
		return time.Unix(0, 0), nil
	}

	return sourcesMaxTime, nil
}

// Added to track the oldest artifact constraint
func getMinTime(files ...string) (time.Time, error) {
	var minT time.Time
	for i, f := range files {
		info, err := os.Stat(f)
		if err != nil {
			return time.Time{}, err
		}
		modTime := info.ModTime()
		if i == 0 || modTime.Before(minT) {
			minT = modTime
		}
	}
	return minT, nil
}

func getMaxTime(files ...string) (time.Time, error) {
	var maxT time.Time
	for i, f := range files {
		info, err := os.Stat(f)
		if err != nil {
			return time.Time{}, err
		}
		modTime := info.ModTime()
		if i == 0 || modTime.After(maxT) {
			maxT = modTime
		}
	}
	return maxT, nil
}

// If the modification time of any of the files is newer than the given time, returns true.
// This function is lazy, as it stops when it finds a file newer than the given time.
func anyFileNewerThan(files []string, givenTime time.Time) (bool, error) {
	for _, f := range files {
		info, err := os.Stat(f)
		if err != nil {
			return false, err
		}
		if info.ModTime().After(givenTime) {
			return true, nil
		}
	}
	return false, nil
}

// OnError implements the Checker interface
func (*TimestampChecker) OnError(t *ast.Task) error {
	return nil
}

func (checker *TimestampChecker) timestampFilePath(t *ast.Task) string {
	return filepath.Join(checker.tempDir, "timestamp", normalizeFilename(t.Task))
}
Strategy: os.Stat calls improved
package fingerprint

import (
	"os"
	"path/filepath"
	"time"

	"github.com/go-task/task/v3/taskfile/ast"
)

// TimestampChecker checks if any source change compared with the generated files,
// using file modifications timestamps.
type TimestampChecker struct {
	tempDir string
	dry     bool
}

func NewTimestampChecker(tempDir string, dry bool) *TimestampChecker {
	return &TimestampChecker{
		tempDir: tempDir,
		dry:     dry,
	}
}

// IsUpToDate implements the Checker interface
func (checker *TimestampChecker) IsUpToDate(t *ast.Task) (bool, error) {
	if len(t.Sources) == 0 {
		return false, nil
	}

	sources, err := Globs(t.Dir, t.Sources)
	if err != nil {
		return false, nil
	}

	generates, err := Globs(t.Dir, t.Generates)
	if err != nil {
		return false, nil
	}

	if len(t.Generates) > 0 {
		hasPositivePattern := false
		for _, g := range t.Generates {
			if !g.Negate {
				hasPositivePattern = true
				break
			}
		}
		if hasPositivePattern && len(generates) == 0 {
			return false, nil
		}
	}

	timestampFile := checker.timestampFilePath(t)

	_, err = os.Stat(timestampFile)
	if err == nil {
		generates = append(generates, timestampFile)
	} else if !checker.dry {
		if err := os.MkdirAll(filepath.Dir(timestampFile), 0o755); err != nil {
			return false, err
		}
		f, err := os.Create(timestampFile)
		if err != nil {
			return false, err
		}
		f.Close()
	}

	taskTime := time.Now()

	// 1. Establish the absolute baseline boundary (the oldest generated asset)
	var minGenerateTime time.Time
	for i, g := range generates {
		info, err := os.Stat(g)
		if err != nil {
			return false, nil // Missing output asset forces a re-run
		}
		modTime := info.ModTime()
		if i == 0 || modTime.Before(minGenerateTime) {
			minGenerateTime = modTime
		}
	}

	// 2. Interleaved lazy verification check on sources
	// We run os.Stat sequentially and exit the instant a file is found to be stale.
	for _, s := range sources {
		info, err := os.Stat(s)
		if err != nil {
			return false, nil // Missing source file means target cannot be evaluated cleanly
		}
		// If ANY source file is newer than our oldest output asset, it's stale.
		if info.ModTime().After(minGenerateTime) {
			return false, nil
		}
	}

	if !checker.dry {
		if err := os.Chtimes(timestampFile, taskTime, taskTime); err != nil {
			return false, err
		}
	}

	return true, nil
}

func (checker *TimestampChecker) Kind() string {
	return "timestamp"
}

// Value implements the Checker Interface
func (checker *TimestampChecker) Value(t *ast.Task) (any, error) {
	sources, err := Globs(t.Dir, t.Sources)
	if err != nil {
		return time.Now(), err
	}

	var maxT time.Time
	for i, f := range sources {
		info, err := os.Stat(f)
		if err != nil {
			return time.Now(), err
		}
		if i == 0 || info.ModTime().After(maxT) {
			maxT = info.ModTime()
		}
	}

	if maxT.IsZero() {
		return time.Unix(0, 0), nil
	}
	return maxT, nil
}

func (*TimestampChecker) OnError(t *ast.Task) error {
	return nil
}

func (checker *TimestampChecker) timestampFilePath(t *ast.Task) string {
	return filepath.Join(checker.tempDir, "timestamp", normalizeFilename(t.Task))
}

@andreynering andreynering linked an issue Jun 12, 2026 that may be closed by this pull request
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.
@Napolitain

Copy link
Copy Markdown
Contributor Author

Would/could you add an OS Native benchmark too, using mtime. As a reference point.

Having the test profile code might also be useful. This function in particular: https://github.com/go-task/task/blob/main/internal/fingerprint/sources_timestamp.go

Edit: Another point of reference (in addition to mtime) would be to generate a Makefile and run that over the files too.

addressed in ec19102 if I understood that part correctly.

@trulede

trulede commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

I profiled task when running your benchmarks. It seems like the timestamp itself might not be the only issue. There is a lot of templater action ... but I can't understand why.

@Napolitain

Copy link
Copy Markdown
Contributor Author

I profiled task when running your benchmarks. It seems like the timestamp itself might not be the only issue. There is a lot of templater action ... but I can't understand why.

I think we should merge the test only, then create follow up PR for trying to solve the performance issue and over alloc. This way, maintainers can easily revert a performance PR if they judge it to be problematic without removing the benchmark itself (which shouldn't be harmful).

@trulede

trulede commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

I profiled task when running your benchmarks. It seems like the timestamp itself might not be the only issue. There is a lot of templater action ... but I can't understand why.

It might simply be the test data generation. To do a clean profiling it might be necessary to rework that a little.

@Napolitain

Copy link
Copy Markdown
Contributor Author

I profiled task when running your benchmarks. It seems like the timestamp itself might not be the only issue. There is a lot of templater action ... but I can't understand why.

It might simply be the test data generation. To do a clean profiling it might be necessary to rework that a little.

I dont think test data generation is within timed test.

@Napolitain

Napolitain commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

my results

BenchmarkManySmallFiles/checksum-28                    3         427186693 ns/op           0.23 MB/s             0.09537 source_MiB/op     20000 source_files/op      2037247696 B/op   741774 allocs/op
BenchmarkManySmallFiles/timestamp-28                   3         123957822 ns/op           0.81 MB/s             0.09537 source_MiB/op     20000 source_files/op      74913922 B/op     561757 allocs/op
BenchmarkManySmallFiles/native-mtime-28                3          18065517 ns/op           5.54 MB/s             0.09537 source_MiB/op     20000 source_files/op      11561130 B/op     123036 allocs/op
BenchmarkManySmallFiles/none-28                        3            669064 ns/op         2586005 B/op       3189 allocs/op

BenchmarkFewLargeFiles/checksum-28                     3          59824818 ns/op        8974.05 MB/s           512.0 source_MiB/op            4.000 source_files/op    1131821 B/op       2329 allocs/op
BenchmarkFewLargeFiles/timestamp-28                    3            188148 ns/op        2853444.95 MB/s        512.0 source_MiB/op            4.000 source_files/op     328221 B/op       2302 allocs/op
BenchmarkFewLargeFiles/native-mtime-28                 3              9986 ns/op        53764153.15 MB/s               512.0 source_MiB/op             4.000 source_files/op      7440 B/op         45 allocs/op
BenchmarkFewLargeFiles/none-28                         3            855158 ns/op         2598602 B/op       3192 allocs/op

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.
@timrulebosch

Copy link
Copy Markdown

It will be necessary to collect profiling data to figure out a good implementation (using pperf) so I assume it will be necessary to isolate the data generation part of the test.

In any case, you might try this in your MTime comparison.

func ReadDir(name string) ([]DirEntry, error)

It should be possible to retrieve all FileInfo for files in a directory in one system call, rather than 20.000 calls to os.Stat().

@Napolitain

Copy link
Copy Markdown
Contributor Author

@timrulebosch @trulede please check #2883
note that it is stacked PR.

@Napolitain

Copy link
Copy Markdown
Contributor Author

As well as #2884.

@Napolitain

Napolitain commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

It will be necessary to collect profiling data to figure out a good implementation (using pperf) so I assume it will be necessary to isolate the data generation part of the test.

I think -benchtime=50 is decent enough to make the profiling possible ?

To me the PR is decent for adding a benchmark baseline to which followup can improve upon.

@Napolitain

Copy link
Copy Markdown
Contributor Author

Hello @andreynering , what do you think ? I think we can

  • merge the benchmark as a atomic PR
  • try to review and merge small atomic fixes (allowing rollback, if needed)
  • @trulede has some interesting stuff as well we can check

@vmaerten vmaerten self-requested a review June 29, 2026 14:24

@andreynering andreynering left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hi @Napolitain

Thanks for contributing and for the patience!

Sounds good. I added a task to run the benchmark and I'll merge this and take a look at the other PR once I can.

@andreynering andreynering enabled auto-merge (squash) July 11, 2026 14:20
@andreynering andreynering merged commit 5ebb0d1 into go-task:main Jul 11, 2026
19 of 20 checks passed
@andreynering

Copy link
Copy Markdown
Member

I asked GLM-5.2 to summarize the bench results for me. Looking great, thanks @Napolitain!


Ran on branch temp-bench. Here's the comparison against the original benchmark:

Benchmark Comparison — Original vs. Combined Branch

ManySmallFiles (20,000 files × 5 bytes)

Mode ns/op (orig → now) Δ Speed Allocs (orig → now) Δ Allocs
checksum 1,302,755,417 → 432,750,764 66.8% faster 746,646 → 165,112 77.9% fewer
timestamp 302,742,323 → 55,981,302 81.5% faster 566,643 → 105,134 81.4% fewer
native-mtime 49,650,670 → 47,396,528 ~4.5% faster 123,430 → 123,430 no change
none 532,470 → 501,104 ~5.9% faster 3,072 → 3,120 ~1.6% more

FewLargeFiles (4 files × 128 MiB)

Mode ns/op (orig → now) Δ Speed Allocs (orig → now) Δ Allocs
checksum 154,367,571 → 49,673,596 67.8% faster 4,845 → 1,428 70.5% fewer
timestamp 1,070,822 → 153,193 85.7% faster 4,813 → 1,441 70.0% fewer
native-mtime 33,087 → 43,249 ~31% slower 48 → 48 no change
none 516,510 → 551,271 ~6.7% slower 3,072 → 3,121 ~1.6% more

Full Branch Comparison (ManySmallFiles checksum mode)

Branch ns/op Allocs
Original 1,302,755,417 746,646
simple-glob-fast-path 1,006,966,333 (−23%) 492,552 (−34%)
lazy-fingerprint-vars 443,557,958 (−66%) 249,756 (−67%)
Combined (temp-bench) 432,750,764 (−67%) 165,112 (−78%)

Key Takeaways

  • The combined branch produces the best results overall, confirming the two optimizations are complementary.
  • Timestamp on small files sees the biggest combined win: 81.5% faster (303ms → 56ms) with 81% fewer allocations (567K → 105K) — noticeably better than either branch alone.
  • Checksum on small files is 67% faster (1.3s → 433ms), with allocations dropping to 166K — lower than either individual branch (492K / 250K).
  • Large file timestamp hit 86% faster (1.07ms → 153µs) with 70% fewer allocations, again the best of all branches.
  • The none and native-mtime baselines are within noise (the native-mtime variance is expected with only 24-25 iterations).
  • The combined branch is strictly better than or equal to the best individual branch in every checksum/timestamp metric.

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.

Cache is very slow

4 participants