|
| 1 | +package main |
| 2 | + |
| 3 | +import ( |
| 4 | + "bufio" |
| 5 | + "context" |
| 6 | + "encoding/csv" |
| 7 | + "flag" |
| 8 | + "fmt" |
| 9 | + "io" |
| 10 | + "log" |
| 11 | + "os" |
| 12 | + "os/exec" |
| 13 | + "path/filepath" |
| 14 | + "strconv" |
| 15 | + "strings" |
| 16 | + "time" |
| 17 | +) |
| 18 | + |
| 19 | +const ( |
| 20 | + benchstatVersion = "v0.0.0-20251023143056-3684bd442cc8" |
| 21 | +) |
| 22 | + |
| 23 | +type config struct { |
| 24 | + bench string |
| 25 | + benchtime string |
| 26 | + count int |
| 27 | + benchmem bool |
| 28 | + failMetrics []string |
| 29 | + threshold float64 |
| 30 | + installTool bool |
| 31 | + targetDirs []string |
| 32 | + verbose bool |
| 33 | +} |
| 34 | + |
| 35 | +type benchResult struct { |
| 36 | + name string |
| 37 | + timePerOp float64 |
| 38 | + bytesPerOp int64 |
| 39 | + allocsPerOp int64 |
| 40 | +} |
| 41 | + |
| 42 | +func main() { |
| 43 | + cfg := parseFlags() |
| 44 | + |
| 45 | + if cfg.installTool { |
| 46 | + if err := installBenchstat(); err != nil { |
| 47 | + log.Fatalf("failed to install benchstat: %v", err) |
| 48 | + } |
| 49 | + } |
| 50 | + |
| 51 | + if !isBenchstatAvailable() { |
| 52 | + log.Fatalf("benchstat not found in PATH; install golang.org/x/perf/cmd/benchstat@%s in your environment or run with --install-benchstat", benchstatVersion) |
| 53 | + } |
| 54 | + |
| 55 | + if len(cfg.targetDirs) == 0 { |
| 56 | + cfg.targetDirs = findBenchmarkDirs() |
| 57 | + } |
| 58 | + |
| 59 | + if len(cfg.targetDirs) == 0 { |
| 60 | + log.Fatal("no benchmark directories found") |
| 61 | + } |
| 62 | + |
| 63 | + for _, dir := range cfg.targetDirs { |
| 64 | + if err := runBenchmarksForDir(cfg, dir); err != nil { |
| 65 | + log.Printf("failed to run benchmarks for %s: %v", dir, err) |
| 66 | + os.Exit(1) |
| 67 | + } |
| 68 | + } |
| 69 | +} |
| 70 | + |
| 71 | +func parseFlags() config { |
| 72 | + cfg := config{} |
| 73 | + |
| 74 | + flag.StringVar(&cfg.bench, "bench", ".", "benchmark pattern to run") |
| 75 | + flag.StringVar(&cfg.benchtime, "benchtime", "1s", "benchmark time") |
| 76 | + flag.IntVar(&cfg.count, "count", 5, "number of benchmark runs") |
| 77 | + flag.BoolVar(&cfg.benchmem, "benchmem", false, "include memory allocation stats") |
| 78 | + flag.Float64Var(&cfg.threshold, "threshold", 1.5, "performance regression threshold (e.g., 1.5 = 50% slower)") |
| 79 | + flag.BoolVar(&cfg.installTool, "install-benchstat", false, "install benchstat tool if not found") |
| 80 | + flag.BoolVar(&cfg.verbose, "verbose", false, "verbose output") |
| 81 | + |
| 82 | + var failMetricsStr string |
| 83 | + flag.StringVar(&failMetricsStr, "fail-metrics", "", "comma-separated list of metrics to check for regressions (time/op,B/op,allocs/op)") |
| 84 | + |
| 85 | + var targetDirsStr string |
| 86 | + flag.StringVar(&targetDirsStr, "target-dirs", "", "comma-separated list of directories to benchmark") |
| 87 | + |
| 88 | + flag.Parse() |
| 89 | + |
| 90 | + if failMetricsStr != "" { |
| 91 | + cfg.failMetrics = strings.Split(failMetricsStr, ",") |
| 92 | + } |
| 93 | + |
| 94 | + if targetDirsStr != "" { |
| 95 | + cfg.targetDirs = strings.Split(targetDirsStr, ",") |
| 96 | + } |
| 97 | + |
| 98 | + return cfg |
| 99 | +} |
| 100 | + |
| 101 | +func installBenchstat() error { |
| 102 | + fmt.Printf("Installing benchstat@%s...\n", benchstatVersion) |
| 103 | + cmd := exec.Command("go", "install", fmt.Sprintf("golang.org/x/perf/cmd/benchstat@%s", benchstatVersion)) |
| 104 | + cmd.Stdout = os.Stdout |
| 105 | + cmd.Stderr = os.Stderr |
| 106 | + return cmd.Run() |
| 107 | +} |
| 108 | + |
| 109 | +func isBenchstatAvailable() bool { |
| 110 | + _, err := exec.LookPath("benchstat") |
| 111 | + return err == nil |
| 112 | +} |
| 113 | + |
| 114 | +func findBenchmarkDirs() []string { |
| 115 | + var dirs []string |
| 116 | + |
| 117 | + err := filepath.WalkDir("internal/namespaces", func(path string, d os.DirEntry, err error) error { |
| 118 | + if err != nil { |
| 119 | + return err |
| 120 | + } |
| 121 | + |
| 122 | + if d.IsDir() { |
| 123 | + return nil |
| 124 | + } |
| 125 | + |
| 126 | + if strings.HasSuffix(d.Name(), "_benchmark_test.go") { |
| 127 | + dir := filepath.Dir(path) |
| 128 | + dirs = append(dirs, dir) |
| 129 | + } |
| 130 | + |
| 131 | + return nil |
| 132 | + }) |
| 133 | + |
| 134 | + if err != nil { |
| 135 | + log.Printf("error scanning for benchmark directories: %v", err) |
| 136 | + } |
| 137 | + |
| 138 | + return dirs |
| 139 | +} |
| 140 | + |
| 141 | +func runBenchmarksForDir(cfg config, dir string) error { |
| 142 | + fmt.Printf(">>> Running benchmarks for %s\n", dir) |
| 143 | + |
| 144 | + baselineFile := filepath.Join(dir, "testdata", "benchmark.baseline") |
| 145 | + |
| 146 | + // Run benchmarks |
| 147 | + newResults, err := runBenchmarks(cfg, dir) |
| 148 | + if err != nil { |
| 149 | + return fmt.Errorf("failed to run benchmarks: %w", err) |
| 150 | + } |
| 151 | + |
| 152 | + // Check if baseline exists |
| 153 | + if _, err := os.Stat(baselineFile); os.IsNotExist(err) { |
| 154 | + fmt.Printf("No baseline found at %s. Creating new baseline.\n", baselineFile) |
| 155 | + if err := saveBaseline(baselineFile, newResults); err != nil { |
| 156 | + return fmt.Errorf("failed to save baseline: %w", err) |
| 157 | + } |
| 158 | + fmt.Printf("Baseline saved to %s\n", baselineFile) |
| 159 | + return nil |
| 160 | + } |
| 161 | + |
| 162 | + // Compare with baseline |
| 163 | + return compareWithBaseline(cfg, baselineFile, newResults) |
| 164 | +} |
| 165 | + |
| 166 | +func runBenchmarks(cfg config, dir string) (string, error) { |
| 167 | + args := []string{"test", "-bench=" + cfg.bench, "-benchtime=" + cfg.benchtime, "-count=" + strconv.Itoa(cfg.count)} |
| 168 | + |
| 169 | + if cfg.benchmem { |
| 170 | + args = append(args, "-benchmem") |
| 171 | + } |
| 172 | + |
| 173 | + args = append(args, "./"+dir) |
| 174 | + |
| 175 | + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) |
| 176 | + defer cancel() |
| 177 | + |
| 178 | + cmd := exec.CommandContext(ctx, "go", args...) |
| 179 | + cmd.Env = append(os.Environ(), "CLI_RUN_BENCHMARKS=true") |
| 180 | + |
| 181 | + if cfg.verbose { |
| 182 | + fmt.Printf("Running: go %s\n", strings.Join(args, " ")) |
| 183 | + } |
| 184 | + |
| 185 | + output, err := cmd.CombinedOutput() |
| 186 | + if err != nil { |
| 187 | + return "", fmt.Errorf("benchmark execution failed: %w\nOutput: %s", err, output) |
| 188 | + } |
| 189 | + |
| 190 | + return string(output), nil |
| 191 | +} |
| 192 | + |
| 193 | +func saveBaseline(filename, content string) error { |
| 194 | + dir := filepath.Dir(filename) |
| 195 | + if err := os.MkdirAll(dir, 0755); err != nil { |
| 196 | + return err |
| 197 | + } |
| 198 | + |
| 199 | + return os.WriteFile(filename, []byte(content), 0644) |
| 200 | +} |
| 201 | + |
| 202 | +func compareWithBaseline(cfg config, baselineFile, newResults string) error { |
| 203 | + // Create temporary file for new results |
| 204 | + tmpFile, err := os.CreateTemp("", "benchmark-new-*.txt") |
| 205 | + if err != nil { |
| 206 | + return fmt.Errorf("failed to create temp file: %w", err) |
| 207 | + } |
| 208 | + defer os.Remove(tmpFile.Name()) |
| 209 | + defer tmpFile.Close() |
| 210 | + |
| 211 | + if _, err := tmpFile.WriteString(newResults); err != nil { |
| 212 | + return fmt.Errorf("failed to write new results: %w", err) |
| 213 | + } |
| 214 | + tmpFile.Close() |
| 215 | + |
| 216 | + // Run benchstat comparison |
| 217 | + cmd := exec.Command("benchstat", "-format=csv", baselineFile, tmpFile.Name()) |
| 218 | + output, err := cmd.Output() |
| 219 | + if err != nil { |
| 220 | + return fmt.Errorf("failed to compare with benchstat for %s: %w", filepath.Dir(baselineFile), err) |
| 221 | + } |
| 222 | + |
| 223 | + // Parse CSV output and check for regressions |
| 224 | + return checkForRegressions(cfg, string(output)) |
| 225 | +} |
| 226 | + |
| 227 | +func checkForRegressions(cfg config, csvOutput string) error { |
| 228 | + reader := csv.NewReader(strings.NewReader(csvOutput)) |
| 229 | + records, err := reader.ReadAll() |
| 230 | + if err != nil { |
| 231 | + return fmt.Errorf("failed to parse benchstat CSV output: %w", err) |
| 232 | + } |
| 233 | + |
| 234 | + if len(records) < 2 { |
| 235 | + fmt.Println("No benchmark comparisons found") |
| 236 | + return nil |
| 237 | + } |
| 238 | + |
| 239 | + // Find column indices |
| 240 | + header := records[0] |
| 241 | + nameIdx := findColumnIndex(header, "name") |
| 242 | + oldTimeIdx := findColumnIndex(header, "old time/op") |
| 243 | + newTimeIdx := findColumnIndex(header, "new time/op") |
| 244 | + oldBytesIdx := findColumnIndex(header, "old B/op") |
| 245 | + newBytesIdx := findColumnIndex(header, "new B/op") |
| 246 | + oldAllocsIdx := findColumnIndex(header, "old allocs/op") |
| 247 | + newAllocsIdx := findColumnIndex(header, "new allocs/op") |
| 248 | + |
| 249 | + if nameIdx == -1 { |
| 250 | + return fmt.Errorf("could not find 'name' column in benchstat output") |
| 251 | + } |
| 252 | + |
| 253 | + var regressions []string |
| 254 | + |
| 255 | + for i, record := range records[1:] { |
| 256 | + if len(record) <= nameIdx { |
| 257 | + continue |
| 258 | + } |
| 259 | + |
| 260 | + benchName := record[nameIdx] |
| 261 | + |
| 262 | + // Check time/op regression |
| 263 | + if contains(cfg.failMetrics, "time/op") && oldTimeIdx != -1 && newTimeIdx != -1 { |
| 264 | + if regression := checkMetricRegression(record, oldTimeIdx, newTimeIdx, cfg.threshold); regression != "" { |
| 265 | + regressions = append(regressions, fmt.Sprintf("%s: time/op %s", benchName, regression)) |
| 266 | + } |
| 267 | + } |
| 268 | + |
| 269 | + // Check B/op regression |
| 270 | + if contains(cfg.failMetrics, "B/op") && oldBytesIdx != -1 && newBytesIdx != -1 { |
| 271 | + if regression := checkMetricRegression(record, oldBytesIdx, newBytesIdx, cfg.threshold); regression != "" { |
| 272 | + regressions = append(regressions, fmt.Sprintf("%s: B/op %s", benchName, regression)) |
| 273 | + } |
| 274 | + } |
| 275 | + |
| 276 | + // Check allocs/op regression |
| 277 | + if contains(cfg.failMetrics, "allocs/op") && oldAllocsIdx != -1 && newAllocsIdx != -1 { |
| 278 | + if regression := checkMetricRegression(record, oldAllocsIdx, newAllocsIdx, cfg.threshold); regression != "" { |
| 279 | + regressions = append(regressions, fmt.Sprintf("%s: allocs/op %s", benchName, regression)) |
| 280 | + } |
| 281 | + } |
| 282 | + |
| 283 | + if cfg.verbose && i < 5 { // Show first few comparisons |
| 284 | + fmt.Printf(" %s: compared\n", benchName) |
| 285 | + } |
| 286 | + } |
| 287 | + |
| 288 | + // Print full benchstat output |
| 289 | + fmt.Println("Benchmark comparison results:") |
| 290 | + fmt.Println(csvOutput) |
| 291 | + |
| 292 | + if len(regressions) > 0 { |
| 293 | + fmt.Printf("\n❌ Performance regressions detected (threshold: %.1fx):\n", cfg.threshold) |
| 294 | + for _, regression := range regressions { |
| 295 | + fmt.Printf(" - %s\n", regression) |
| 296 | + } |
| 297 | + return fmt.Errorf("performance regressions detected") |
| 298 | + } |
| 299 | + |
| 300 | + fmt.Printf("✅ No significant performance regressions detected (threshold: %.1fx)\n", cfg.threshold) |
| 301 | + return nil |
| 302 | +} |
| 303 | + |
| 304 | +func findColumnIndex(header []string, columnName string) int { |
| 305 | + for i, col := range header { |
| 306 | + if strings.Contains(strings.ToLower(col), strings.ToLower(columnName)) { |
| 307 | + return i |
| 308 | + } |
| 309 | + } |
| 310 | + return -1 |
| 311 | +} |
| 312 | + |
| 313 | +func checkMetricRegression(record []string, oldIdx, newIdx int, threshold float64) string { |
| 314 | + if oldIdx >= len(record) || newIdx >= len(record) { |
| 315 | + return "" |
| 316 | + } |
| 317 | + |
| 318 | + oldVal, err1 := parseMetricValue(record[oldIdx]) |
| 319 | + newVal, err2 := parseMetricValue(record[newIdx]) |
| 320 | + |
| 321 | + if err1 != nil || err2 != nil || oldVal == 0 { |
| 322 | + return "" |
| 323 | + } |
| 324 | + |
| 325 | + ratio := newVal / oldVal |
| 326 | + if ratio > threshold { |
| 327 | + return fmt.Sprintf("%.2fx slower (%.2f → %.2f)", ratio, oldVal, newVal) |
| 328 | + } |
| 329 | + |
| 330 | + return "" |
| 331 | +} |
| 332 | + |
| 333 | +func parseMetricValue(s string) (float64, error) { |
| 334 | + // Remove common suffixes and parse |
| 335 | + s = strings.TrimSpace(s) |
| 336 | + s = strings.ReplaceAll(s, "ns", "") |
| 337 | + s = strings.ReplaceAll(s, "B", "") |
| 338 | + s = strings.TrimSpace(s) |
| 339 | + |
| 340 | + if s == "" || s == "-" { |
| 341 | + return 0, fmt.Errorf("empty value") |
| 342 | + } |
| 343 | + |
| 344 | + return strconv.ParseFloat(s, 64) |
| 345 | +} |
| 346 | + |
| 347 | +func contains(slice []string, item string) bool { |
| 348 | + for _, s := range slice { |
| 349 | + if s == item { |
| 350 | + return true |
| 351 | + } |
| 352 | + } |
| 353 | + return false |
| 354 | +} |
0 commit comments