Skip to content

Commit 0d9f489

Browse files
committed
feat(rdb): add benchmark comparison tool with regression detection
1 parent 8058a7e commit 0d9f489

File tree

1 file changed

+354
-0
lines changed

1 file changed

+354
-0
lines changed

cmd/scw-benchstat/main.go

Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
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

Comments
 (0)