diff --git a/Makefile b/Makefile index 0a5ffb1..744ee8d 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,17 @@ test: test-unit: go test -v ./core/dao/*** +bench: + go test -bench=. -benchmem ./core/dao/... ./core/... + +bench-save: + @mkdir -p benchmarks + @echo "Saving benchmark results to benchmarks/bench-$(shell date +%Y%m%d-%H%M%S).txt..." + go test -bench=. -benchmem ./core/dao/... ./core/... > benchmarks/bench-$(shell date +%Y%m%d-%H%M%S).txt 2>&1 + +bench-compare: + @if [ -n "$(OLD)" ] && [ -n "$(NEW)" ]; then benchstat $(OLD) $(NEW); fi + test-integration: ./test/scripts/test --count 5 --build --clean @@ -60,4 +71,4 @@ release: clean: $(RM) -r dist target -.PHONY: tidy gofmt lint test test-unit test-integration update-golden-files build build-all build-test gen-man release clean +.PHONY: tidy gofmt lint test test-unit test-integration update-golden-files build build-all build-test gen-man release clean bench bench-save bench-compare diff --git a/benchmarks/.gitignore b/benchmarks/.gitignore new file mode 100644 index 0000000..7a9196c --- /dev/null +++ b/benchmarks/.gitignore @@ -0,0 +1,3 @@ +# Ignore benchmark output files (they can be large and change frequently) +# Use `make bench-save` to generate new results +*.txt diff --git a/core/dao/benchmark_test.go b/core/dao/benchmark_test.go new file mode 100644 index 0000000..dfb1fb7 --- /dev/null +++ b/core/dao/benchmark_test.go @@ -0,0 +1,339 @@ +package dao + +import ( + "fmt" + "testing" +) + +// Helper to create a config with N projects, M tasks, and default specs/themes/targets +func createBenchmarkConfig(numProjects, numTasks int) Config { + config := Config{} + + // Create projects + config.ProjectList = make([]Project, numProjects) + for i := 0; i < numProjects; i++ { + config.ProjectList[i] = Project{ + Name: fmt.Sprintf("project-%d", i), + Path: fmt.Sprintf("/path/to/project-%d", i), + RelPath: fmt.Sprintf("project-%d", i), + Tags: []string{"tag1", "tag2"}, + } + } + + // Create tasks + config.TaskList = make([]Task, numTasks) + for i := 0; i < numTasks; i++ { + config.TaskList[i] = Task{ + Name: fmt.Sprintf("task-%d", i), + Cmd: fmt.Sprintf("echo task %d", i), + } + } + + // Create specs + config.SpecList = []Spec{ + {Name: "default", Output: "stream", Forks: 4}, + {Name: "parallel", Output: "stream", Parallel: true, Forks: 8}, + } + + // Create themes + config.ThemeList = []Theme{ + {Name: "default"}, + {Name: "custom"}, + } + + // Create targets + config.TargetList = []Target{ + {Name: "default", All: true}, + {Name: "frontend", Tags: []string{"frontend"}}, + } + + return config +} + +// Lookup_GetProject: Find project by name (O(n) linear search) +func BenchmarkLookup_GetProject(b *testing.B) { + sizes := []int{10, 50, 100, 500} + + for _, size := range sizes { + b.Run(fmt.Sprintf("projects_%d", size), func(b *testing.B) { + config := createBenchmarkConfig(size, 10) + // Look up a project in the middle + targetName := fmt.Sprintf("project-%d", size/2) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = config.GetProject(targetName) + } + }) + } +} + +// Lookup_GetTask: Find task by name (O(n) linear search) +func BenchmarkLookup_GetTask(b *testing.B) { + sizes := []int{10, 50, 100, 500} + + for _, size := range sizes { + b.Run(fmt.Sprintf("tasks_%d", size), func(b *testing.B) { + config := createBenchmarkConfig(10, size) + // Look up a task in the middle + targetName := fmt.Sprintf("task-%d", size/2) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = config.GetTask(targetName) + } + }) + } +} + +// Lookup_GetSpec: Find spec by name +func BenchmarkLookup_GetSpec(b *testing.B) { + config := createBenchmarkConfig(10, 10) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = config.GetSpec("default") + } +} + +// Lookup_GetTheme: Find theme by name +func BenchmarkLookup_GetTheme(b *testing.B) { + config := createBenchmarkConfig(10, 10) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = config.GetTheme("default") + } +} + +// Lookup_GetTarget: Find target by name +func BenchmarkLookup_GetTarget(b *testing.B) { + config := createBenchmarkConfig(10, 10) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = config.GetTarget("default") + } +} + +// Filter_ByName: Filter projects by name list +func BenchmarkFilter_ByName(b *testing.B) { + sizes := []int{10, 50, 100} + + for _, size := range sizes { + b.Run(fmt.Sprintf("projects_%d", size), func(b *testing.B) { + config := createBenchmarkConfig(size, 10) + // Look up 5 projects + names := []string{ + fmt.Sprintf("project-%d", size/5), + fmt.Sprintf("project-%d", size/4), + fmt.Sprintf("project-%d", size/3), + fmt.Sprintf("project-%d", size/2), + fmt.Sprintf("project-%d", size-1), + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = config.GetProjectsByName(names) + } + }) + } +} + +// Filter_ByTags: Filter projects by tags +func BenchmarkFilter_ByTags(b *testing.B) { + sizes := []int{10, 50, 100, 500} + + for _, size := range sizes { + b.Run(fmt.Sprintf("projects_%d", size), func(b *testing.B) { + config := createBenchmarkConfig(size, 10) + tags := []string{"tag1"} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = config.GetProjectsByTags(tags) + } + }) + } +} + +// Filter_ByPath: Filter by path patterns (simple, *, **) +func BenchmarkFilter_ByPath(b *testing.B) { + sizes := []int{10, 50, 100} + + for _, size := range sizes { + b.Run(fmt.Sprintf("projects_%d_simple", size), func(b *testing.B) { + config := createBenchmarkConfig(size, 10) + paths := []string{"project-1"} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = config.GetProjectsByPath(paths) + } + }) + + b.Run(fmt.Sprintf("projects_%d_glob", size), func(b *testing.B) { + config := createBenchmarkConfig(size, 10) + paths := []string{"project-*"} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = config.GetProjectsByPath(paths) + } + }) + + b.Run(fmt.Sprintf("projects_%d_doubleglob", size), func(b *testing.B) { + config := createBenchmarkConfig(size, 10) + paths := []string{"**/project-*"} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = config.GetProjectsByPath(paths) + } + }) + } +} + +// Filter_Combined: FilterProjects with multiple criteria +func BenchmarkFilter_Combined(b *testing.B) { + sizes := []int{10, 50, 100} + + for _, size := range sizes { + b.Run(fmt.Sprintf("projects_%d_all", size), func(b *testing.B) { + config := createBenchmarkConfig(size, 10) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = config.FilterProjects(false, true, nil, nil, nil, "") + } + }) + + b.Run(fmt.Sprintf("projects_%d_bytags", size), func(b *testing.B) { + config := createBenchmarkConfig(size, 10) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = config.FilterProjects(false, false, nil, nil, []string{"tag1"}, "") + } + }) + } +} + +// Util_ConfigLoad: Simulates config loading (ParseTask lookups) +func BenchmarkUtil_ConfigLoad(b *testing.B) { + taskCounts := []int{10, 25, 50, 100} + + for _, numTasks := range taskCounts { + b.Run(fmt.Sprintf("tasks_%d", numTasks), func(b *testing.B) { + config := createBenchmarkConfig(50, numTasks) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + // Simulate what happens during config load: + // Each task calls GetTheme, GetSpec, GetTarget + for j := 0; j < numTasks; j++ { + _, _ = config.GetTheme("default") + _, _ = config.GetSpec("default") + _, _ = config.GetTarget("default") + } + } + }) + } +} + +// Lookup_GetCommand: Find task and convert to command +func BenchmarkLookup_GetCommand(b *testing.B) { + sizes := []int{10, 50, 100, 500} + + for _, size := range sizes { + b.Run(fmt.Sprintf("tasks_%d", size), func(b *testing.B) { + config := createBenchmarkConfig(10, size) + targetName := fmt.Sprintf("task-%d", size/2) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = config.GetCommand(targetName) + } + }) + } +} + +// Filter_ByTagsExpr: Filter using tag expressions (&&, ||, !) +func BenchmarkFilter_ByTagsExpr(b *testing.B) { + sizes := []int{10, 50, 100} + + for _, size := range sizes { + b.Run(fmt.Sprintf("projects_%d_simple", size), func(b *testing.B) { + config := createBenchmarkConfig(size, 10) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = config.GetProjectsByTagsExpr("tag1") + } + }) + + b.Run(fmt.Sprintf("projects_%d_and", size), func(b *testing.B) { + config := createBenchmarkConfig(size, 10) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = config.GetProjectsByTagsExpr("tag1 && tag2") + } + }) + + b.Run(fmt.Sprintf("projects_%d_or", size), func(b *testing.B) { + config := createBenchmarkConfig(size, 10) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = config.GetProjectsByTagsExpr("tag1 || tag2") + } + }) + + b.Run(fmt.Sprintf("projects_%d_complex", size), func(b *testing.B) { + config := createBenchmarkConfig(size, 10) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = config.GetProjectsByTagsExpr("(tag1 && tag2) || !tag3") + } + }) + } +} + +// Util_GetCwdProject: Find project matching current directory +func BenchmarkUtil_GetCwdProject(b *testing.B) { + sizes := []int{10, 50, 100, 500} + + for _, size := range sizes { + b.Run(fmt.Sprintf("projects_%d", size), func(b *testing.B) { + config := createBenchmarkConfig(size, 10) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + // This will search through all projects + // In real usage, it matches against cwd + _, _ = config.GetCwdProject() + } + }) + } +} + +// Filter_Intersect: Intersection of project lists +func BenchmarkFilter_Intersect(b *testing.B) { + sizes := []int{10, 50, 100} + + for _, size := range sizes { + b.Run(fmt.Sprintf("projects_%d", size), func(b *testing.B) { + config := createBenchmarkConfig(size, 10) + // Create two overlapping project lists + list1 := config.ProjectList[:size/2] + list2 := config.ProjectList[size/4:] + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = config.GetIntersectProjects(list1, list2) + } + }) + } +} diff --git a/core/prefixer_benchmark_test.go b/core/prefixer_benchmark_test.go new file mode 100644 index 0000000..cf72193 --- /dev/null +++ b/core/prefixer_benchmark_test.go @@ -0,0 +1,121 @@ +package core + +import ( + "bytes" + "fmt" + "io" + "strings" + "testing" +) + +// Prefixer_Read: Read() with varying line counts and sizes +func BenchmarkPrefixer_Read(b *testing.B) { + lineCounts := []int{10, 100, 1000} + lineSizes := []int{50, 200, 500} + + for _, lineCount := range lineCounts { + for _, lineSize := range lineSizes { + name := fmt.Sprintf("lines_%d_size_%d", lineCount, lineSize) + b.Run(name, func(b *testing.B) { + // Create input with specified number of lines + var input strings.Builder + line := strings.Repeat("x", lineSize) + "\n" + for i := 0; i < lineCount; i++ { + input.WriteString(line) + } + inputStr := input.String() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + reader := strings.NewReader(inputStr) + prefixer := NewPrefixer(reader, "[project-name] ") + + buf := make([]byte, 4096) + for { + _, err := prefixer.Read(buf) + if err == io.EOF { + break + } + } + } + }, + ) + } + } +} + +// Prefixer_WriteTo: WriteTo() with varying line counts +func BenchmarkPrefixer_WriteTo(b *testing.B) { + lineCounts := []int{10, 100, 1000} + + for _, lineCount := range lineCounts { + name := fmt.Sprintf("lines_%d", lineCount) + b.Run(name, func(b *testing.B) { + // Create input with specified number of lines + var input strings.Builder + line := strings.Repeat("x", 80) + "\n" + for i := 0; i < lineCount; i++ { + input.WriteString(line) + } + inputStr := input.String() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + reader := strings.NewReader(inputStr) + prefixer := NewPrefixer(reader, "[project-name] ") + + var buf bytes.Buffer + _, _ = prefixer.WriteTo(&buf) + } + }) + } +} + +// Prefixer_PrefixLen: Impact of prefix length on performance +func BenchmarkPrefixer_PrefixLen(b *testing.B) { + prefixLengths := []int{10, 50, 100} + + for _, prefixLen := range prefixLengths { + name := fmt.Sprintf("prefix_%d", prefixLen) + b.Run(name, func(b *testing.B) { + // Create input with 100 lines + var input strings.Builder + line := strings.Repeat("x", 80) + "\n" + for i := 0; i < 100; i++ { + input.WriteString(line) + } + inputStr := input.String() + prefix := strings.Repeat("P", prefixLen) + " " + + b.ResetTimer() + for i := 0; i < b.N; i++ { + reader := strings.NewReader(inputStr) + prefixer := NewPrefixer(reader, prefix) + + var buf bytes.Buffer + _, _ = prefixer.WriteTo(&buf) + } + }) + } +} + +// Prefixer_Allocs: Memory allocation count (optimization target) +func BenchmarkPrefixer_Allocs(b *testing.B) { + // Create input with 100 lines + var input strings.Builder + line := strings.Repeat("x", 80) + "\n" + for i := 0; i < 100; i++ { + input.WriteString(line) + } + inputStr := input.String() + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + reader := strings.NewReader(inputStr) + prefixer := NewPrefixer(reader, "[project-name] ") + + var buf bytes.Buffer + _, _ = prefixer.WriteTo(&buf) + } +}