diff options
| author | Paul Buetow <paul@buetow.org> | 2025-06-26 22:19:22 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-06-26 22:19:22 +0300 |
| commit | 947e08e4f9e3c9c44b346adff4eb6d68fa79a726 (patch) | |
| tree | cfa94aba72f91d26657de09b7a5b6a23eff10fd7 /internal/tools/benchmark | |
| parent | 1e643ac66765fc0ab4224335191731d8b77fece2 (diff) | |
Refactor profiling and benchmarking tools from bash to Go
This major refactoring replaces all bash-based profiling and benchmarking
scripts with a unified Go tool (dtail-tools) that provides:
- Better cross-platform compatibility
- Improved error handling and reliability
- Structured data generation for test files
- Consistent command-line interface
- Easier maintenance and extensibility
Key changes:
- Created dtail-tools command with profile and benchmark subcommands
- Implemented common utilities for data generation and file operations
- Updated Makefile to use the new Go-based tools
- Maintained backward compatibility with existing make targets
- Fixed ParseSize to handle single-letter suffixes (10M, 1G, etc.)
The new tool supports all previous functionality:
- profile-quick, profile-all, profile-dmap
- benchmark creation, comparison, and management
- Test data generation with multiple formats
- Profile analysis and listing
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'internal/tools/benchmark')
| -rw-r--r-- | internal/tools/benchmark/benchmark.go | 385 |
1 files changed, 385 insertions, 0 deletions
diff --git a/internal/tools/benchmark/benchmark.go b/internal/tools/benchmark/benchmark.go new file mode 100644 index 0000000..b728329 --- /dev/null +++ b/internal/tools/benchmark/benchmark.go @@ -0,0 +1,385 @@ +package benchmark + +import ( + "bufio" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/mimecast/dtail/internal/tools/common" +) + +// Config holds benchmark configuration +type Config struct { + Mode string + BaselineDir string + Tag string + Quick bool + Memory bool + OutputFile string + Verbose bool + Iterations string + BaselinePath string +} + +// Run executes the benchmark command +func Run() error { + cfg := parseFlags() + + // Create baseline directory if needed + if err := common.EnsureDirectory(cfg.BaselineDir); err != nil { + return fmt.Errorf("failed to create baseline directory: %w", err) + } + + switch cfg.Mode { + case "run": + return runBenchmarks(cfg) + case "baseline": + return createBaseline(cfg) + case "compare": + return compareWithBaseline(cfg) + case "list": + return listBaselines(cfg) + case "clean": + return cleanBaselines(cfg) + default: + return fmt.Errorf("unknown benchmark mode: %s", cfg.Mode) + } +} + +func parseFlags() *Config { + cfg := &Config{ + BaselineDir: "benchmarks/baselines", + Iterations: "1x", + } + + flag.StringVar(&cfg.Mode, "mode", "run", "Benchmark mode: run, baseline, compare, list, clean") + flag.StringVar(&cfg.BaselineDir, "dir", cfg.BaselineDir, "Baseline directory") + flag.StringVar(&cfg.Tag, "tag", "", "Tag for baseline (e.g., 'before-optimization')") + flag.BoolVar(&cfg.Quick, "quick", false, "Run only quick benchmarks") + flag.BoolVar(&cfg.Memory, "memory", false, "Include memory profiling") + flag.StringVar(&cfg.OutputFile, "output", "", "Output file for results") + flag.BoolVar(&cfg.Verbose, "verbose", false, "Verbose output") + flag.StringVar(&cfg.Iterations, "iterations", cfg.Iterations, "Benchmark iterations (e.g., 3x)") + flag.StringVar(&cfg.BaselinePath, "baseline", "", "Baseline file for comparison") + + flag.Parse() + + // Handle positional arguments for compare mode + if cfg.Mode == "compare" && cfg.BaselinePath == "" { + args := flag.Args() + if len(args) > 0 { + cfg.BaselinePath = args[0] + } + } + + return cfg +} + +func runBenchmarks(cfg *Config) error { + common.PrintSection("Running DTail Benchmarks") + + // Build binaries + common.PrintInfo("Building binaries...\n") + if err := common.BuildCommands("dcat", "dgrep", "dmap", "dtail", "dserver"); err != nil { + return fmt.Errorf("failed to build binaries: %w", err) + } + + // Prepare benchmark command + args := []string{"test", "-bench=."} + if cfg.Quick { + args = append(args, "-bench=BenchmarkQuick") + } + if cfg.Memory { + args = append(args, "-benchmem") + } + if cfg.Iterations != "1x" { + args = append(args, fmt.Sprintf("-benchtime=%s", cfg.Iterations)) + } + if cfg.Verbose { + args = append(args, "-v") + } + args = append(args, "./benchmarks") + + // Run benchmarks + cmd := exec.Command("go", args...) + + var output []byte + var err error + + if cfg.OutputFile != "" { + // Capture output for file + output, err = cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("benchmark failed: %w\n%s", err, string(output)) + } + + // Write to file + if err := os.WriteFile(cfg.OutputFile, output, 0644); err != nil { + return fmt.Errorf("failed to write output file: %w", err) + } + + // Also print to stdout + fmt.Print(string(output)) + common.PrintSuccess("\nResults saved to: %s\n", cfg.OutputFile) + } else { + // Direct output to stdout + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("benchmark failed: %w", err) + } + } + + return nil +} + +func createBaseline(cfg *Config) error { + if cfg.Tag == "" { + return fmt.Errorf("baseline tag is required (use -tag)") + } + + common.PrintSection("Creating Benchmark Baseline") + + // Generate filename + timestamp := time.Now().Format("20060102_150405") + safeTag := strings.ReplaceAll(cfg.Tag, " ", "_") + safeTag = strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' { + return r + } + return '_' + }, safeTag) + + filename := filepath.Join(cfg.BaselineDir, + fmt.Sprintf("baseline_%s_%s.txt", timestamp, safeTag)) + + // Create baseline file with metadata + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create baseline file: %w", err) + } + defer file.Close() + + // Write metadata + fmt.Fprintf(file, "Git commit: %s\n", common.GetGitCommit()) + fmt.Fprintf(file, "Date: %s\n", time.Now().Format(time.RFC3339)) + fmt.Fprintf(file, "Tag: %s\n", cfg.Tag) + fmt.Fprintf(file, "----------------------------------------\n") + + // Run benchmarks and capture output + args := []string{"test", "-bench=.", "-benchmem"} + if cfg.Quick { + args = append(args, "-bench=BenchmarkQuick") + } + if cfg.Iterations != "1x" && cfg.Iterations != "" { + args = append(args, fmt.Sprintf("-benchtime=%s", cfg.Iterations)) + } + args = append(args, "./benchmarks") + + cmd := exec.Command("go", args...) + cmd.Stdout = io.MultiWriter(file, os.Stdout) + cmd.Stderr = os.Stderr + + common.PrintInfo("Running benchmarks for baseline...\n") + if err := cmd.Run(); err != nil { + return fmt.Errorf("benchmark failed: %w", err) + } + + common.PrintSuccess("\nBaseline saved to: %s\n", filename) + return nil +} + +func compareWithBaseline(cfg *Config) error { + if cfg.BaselinePath == "" { + return fmt.Errorf("baseline file required (use -baseline or specify as argument)") + } + + if !common.FileExists(cfg.BaselinePath) { + return fmt.Errorf("baseline file not found: %s", cfg.BaselinePath) + } + + common.PrintSection("Comparing with Baseline") + fmt.Printf("Baseline: %s\n\n", cfg.BaselinePath) + + // Run current benchmarks + currentFile := filepath.Join(cfg.BaselineDir, "current.txt") + args := []string{"test", "-bench=.", "-benchmem"} + + // Check if baseline is quick mode + baselineContent, err := os.ReadFile(cfg.BaselinePath) + if err != nil { + return fmt.Errorf("failed to read baseline: %w", err) + } + if strings.Contains(string(baselineContent), "BenchmarkQuick") { + args = append(args, "-bench=BenchmarkQuick") + } + + args = append(args, "./benchmarks") + + cmd := exec.Command("go", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("benchmark failed: %w\n%s", err, string(output)) + } + + // Save current results + if err := os.WriteFile(currentFile, output, 0644); err != nil { + return fmt.Errorf("failed to write current results: %w", err) + } + + // Print current results + fmt.Println("Current benchmark results:") + fmt.Println(string(output)) + + common.PrintSection("Comparison Report") + + // Try benchstat first + if err := runBenchstat(cfg.BaselinePath, currentFile); err != nil { + // Fall back to simple diff + common.PrintInfo("benchstat not found, showing simple diff:\n\n") + if err := showSimpleDiff(cfg.BaselinePath, currentFile); err != nil { + return fmt.Errorf("failed to show diff: %w", err) + } + } + + // Save comparison report + reportFile := filepath.Join(cfg.BaselineDir, + fmt.Sprintf("comparison_%s.txt", time.Now().Format("20060102_150405"))) + + report := fmt.Sprintf("Comparison Report\n"+ + "Generated: %s\n"+ + "Baseline: %s\n"+ + "Current: %s\n"+ + "================================================================================\n\n", + time.Now().Format(time.RFC3339), + cfg.BaselinePath, + currentFile) + + if err := os.WriteFile(reportFile, []byte(report), 0644); err != nil { + common.PrintError("Failed to save comparison report: %v\n", err) + } else { + common.PrintInfo("\nComparison report saved to: %s\n", reportFile) + } + + return nil +} + +func listBaselines(cfg *Config) error { + common.PrintSection("Available Baselines") + + pattern := filepath.Join(cfg.BaselineDir, "baseline_*.txt") + files, err := filepath.Glob(pattern) + if err != nil { + return fmt.Errorf("failed to list baselines: %w", err) + } + + if len(files) == 0 { + fmt.Printf("No baselines found in %s\n", cfg.BaselineDir) + return nil + } + + // Sort by modification time (newest first) + sort.Slice(files, func(i, j int) bool { + fi, _ := os.Stat(files[i]) + fj, _ := os.Stat(files[j]) + return fi.ModTime().After(fj.ModTime()) + }) + + // Display baselines + for _, file := range files { + info, err := os.Stat(file) + if err != nil { + continue + } + + // Try to extract tag from file + tag := extractTagFromBaseline(file) + + fmt.Printf(" %s %8s %-40s %s\n", + info.ModTime().Format("2006-01-02 15:04:05"), + common.FormatSize(info.Size()), + filepath.Base(file), + tag) + } + + fmt.Printf("\nTotal: %d baselines\n", len(files)) + fmt.Printf("\nUsage: dtail-tools benchmark -mode compare <baseline_file>\n") + + return nil +} + +func cleanBaselines(cfg *Config) error { + common.PrintSection("Cleaning Old Baselines") + + pattern := filepath.Join(cfg.BaselineDir, "baseline_*.txt") + files, err := filepath.Glob(pattern) + if err != nil { + return fmt.Errorf("failed to list baselines: %w", err) + } + + if len(files) <= 10 { + fmt.Println("No old baselines to clean (keeping last 10)") + return nil + } + + // Sort by modification time (oldest first) + sort.Slice(files, func(i, j int) bool { + fi, _ := os.Stat(files[i]) + fj, _ := os.Stat(files[j]) + return fi.ModTime().Before(fj.ModTime()) + }) + + // Remove old files + toRemove := files[:len(files)-10] + for _, file := range toRemove { + fmt.Printf("Removing: %s\n", filepath.Base(file)) + if err := os.Remove(file); err != nil { + common.PrintError("Failed to remove %s: %v\n", file, err) + } + } + + common.PrintSuccess("\nRemoved %d old baselines\n", len(toRemove)) + return nil +} + +func extractTagFromBaseline(filename string) string { + file, err := os.Open(filename) + if err != nil { + return "" + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "Tag: ") { + return strings.TrimPrefix(line, "Tag: ") + } + if strings.HasPrefix(line, "----") { + break + } + } + return "" +} + +func runBenchstat(baseline, current string) error { + cmd := exec.Command("benchstat", baseline, current) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func showSimpleDiff(baseline, current string) error { + cmd := exec.Command("diff", "-u", baseline, current) + output, _ := cmd.CombinedOutput() + fmt.Print(string(output)) + return nil +}
\ No newline at end of file |
