github.com/willabides/benchdiff@v0.9.1/cmd/benchdiff/internal/benchdiff.go (about) 1 package internal 2 3 import ( 4 "bytes" 5 "encoding/base64" 6 "encoding/json" 7 "fmt" 8 "io" 9 "log" 10 "os" 11 "os/exec" 12 "path/filepath" 13 "strings" 14 "time" 15 16 "github.com/willabides/benchdiff/pkg/benchstatter" 17 "golang.org/x/crypto/sha3" 18 "golang.org/x/perf/benchstat" 19 ) 20 21 // Benchdiff runs benchstats and outputs their deltas 22 type Benchdiff struct { 23 BenchCmd string 24 BenchArgs string 25 ResultsDir string 26 BaseRef string 27 Path string 28 GitCmd string 29 Writer io.Writer 30 Benchstat *benchstatter.Benchstat 31 Force bool 32 JSONOutput bool 33 Cooldown time.Duration 34 WarmupCount int 35 WarmupTime string 36 Debug *log.Logger 37 } 38 39 type runBenchmarksResults struct { 40 worktreeOutputFile string 41 baseOutputFile string 42 benchmarkCmd string 43 headSHA string 44 baseSHA string 45 } 46 47 func fileExists(path string) bool { 48 _, err := os.Stat(path) 49 if err != nil { 50 return !os.IsNotExist(err) 51 } 52 return true 53 } 54 55 func (c *Benchdiff) debug() *log.Logger { 56 if c.Debug == nil { 57 return log.New(io.Discard, "", 0) 58 } 59 return c.Debug 60 } 61 62 func (c *Benchdiff) gitCmd() string { 63 if c.GitCmd == "" { 64 return "git" 65 } 66 return c.GitCmd 67 } 68 69 func (c *Benchdiff) cacheKey() string { 70 var b []byte 71 b = append(b, []byte(c.BenchCmd)...) 72 b = append(b, []byte(c.BenchArgs)...) 73 sum := sha3.Sum224(b) 74 return base64.RawURLEncoding.EncodeToString(sum[:]) 75 } 76 77 // runCmd runs cmd sending its stdout and stderr to debug.Write() 78 func runCmd(cmd *exec.Cmd, debug *log.Logger) error { 79 if debug == nil { 80 debug = log.New(io.Discard, "", 0) 81 } 82 var bufStderr bytes.Buffer 83 stderr := io.MultiWriter(&bufStderr, debug.Writer()) 84 if cmd.Stderr != nil { 85 stderr = io.MultiWriter(cmd.Stderr, stderr) 86 } 87 cmd.Stderr = stderr 88 stdout := debug.Writer() 89 if cmd.Stdout != nil { 90 stdout = io.MultiWriter(cmd.Stdout, stdout) 91 } 92 cmd.Stdout = stdout 93 debug.Printf("+ %s", cmd) 94 err := cmd.Run() 95 if exitErr, ok := err.(*exec.ExitError); ok { 96 err = fmt.Errorf(`error running command: %s 97 exit code: %d 98 stderr: %s`, cmd.String(), exitErr.ExitCode(), bufStderr.String()) 99 } 100 return err 101 } 102 103 func (c *Benchdiff) runBenchmark(ref, filename, extraArgs string, pause time.Duration, force bool) error { 104 cmd := exec.Command(c.BenchCmd, strings.Fields(c.BenchArgs+" "+extraArgs)...) 105 106 stdlib := false 107 if rootPath, err := runGitCmd(c.debug(), c.gitCmd(), c.Path, "rev-parse", "--show-toplevel"); err == nil { 108 // lib/time/zoneinfo.zip is a specific enough path, and it's here to 109 // stay because it's one of the few paths hardcoded into Go binaries. 110 zoneinfoPath := filepath.Join(string(rootPath), "lib", "time", "zoneinfo.zip") 111 if _, err := os.Stat(zoneinfoPath); err == nil { 112 stdlib = true 113 cmd.Path = filepath.Join(string(rootPath), "bin", "go") 114 } 115 } 116 117 fileBuffer := &bytes.Buffer{} 118 if filename != "" { 119 c.debug().Printf("output file: %s", filename) 120 if ref != "" && !force { 121 if fileExists(filename) { 122 c.debug().Printf("+ skipping benchmark for ref %q because output file exists", ref) 123 return nil 124 } 125 } 126 cmd.Stdout = fileBuffer 127 } 128 129 var runErr error 130 if ref == "" { 131 runErr = runCmd(cmd, c.debug()) 132 } else { 133 err := runAtGitRef(c.debug(), c.gitCmd(), c.Path, c.BaseRef, func(workPath string) { 134 if pause > 0 { 135 time.Sleep(pause) 136 } 137 if stdlib { 138 makeCmd := exec.Command(filepath.Join(workPath, "src", "make.bash")) 139 makeCmd.Dir = filepath.Join(workPath, "src") 140 makeCmd.Env = append(os.Environ(), "GOOS=", "GOARCH=") 141 runErr = runCmd(makeCmd, c.debug()) 142 if runErr != nil { 143 return 144 } 145 cmd.Path = filepath.Join(workPath, "bin", "go") 146 } 147 cmd.Dir = workPath // TODO: add relative path of working directory 148 runErr = runCmd(cmd, c.debug()) 149 }) 150 if err != nil { 151 return err 152 } 153 } 154 if runErr != nil { 155 return runErr 156 } 157 if filename == "" { 158 return nil 159 } 160 return os.WriteFile(filename, fileBuffer.Bytes(), 0o666) 161 } 162 163 func (c *Benchdiff) runBenchmarks() (result *runBenchmarksResults, err error) { 164 headSHA, err := runGitCmd(c.debug(), c.gitCmd(), c.Path, "rev-parse", "HEAD") 165 if err != nil { 166 return nil, err 167 } 168 169 baseSHA, err := runGitCmd(c.debug(), c.gitCmd(), c.Path, "rev-parse", c.BaseRef) 170 if err != nil { 171 return nil, err 172 } 173 174 baseFilename := fmt.Sprintf("benchdiff-%s-%s.out", baseSHA, c.cacheKey()) 175 baseFilename = filepath.Join(c.ResultsDir, baseFilename) 176 177 worktreeFilename := filepath.Join(c.ResultsDir, "benchdiff-worktree.out") 178 179 result = &runBenchmarksResults{ 180 benchmarkCmd: fmt.Sprintf("%s %s", c.BenchCmd, c.BenchArgs), 181 headSHA: strings.TrimSpace(string(headSHA)), 182 baseSHA: strings.TrimSpace(string(baseSHA)), 183 baseOutputFile: baseFilename, 184 worktreeOutputFile: worktreeFilename, 185 } 186 187 doWarmup := c.WarmupCount > 0 188 189 warmupArgs := fmt.Sprintf("-count %d", c.WarmupCount) 190 if c.WarmupTime != "" { 191 warmupArgs = fmt.Sprintf("%s -benchtime %s", warmupArgs, c.WarmupTime) 192 } 193 194 var cooldown time.Duration 195 196 if doWarmup { 197 err = c.runBenchmark(c.BaseRef, "", warmupArgs, cooldown, c.Force) 198 if err != nil { 199 return nil, err 200 } 201 cooldown = c.Cooldown 202 } 203 204 err = c.runBenchmark(c.BaseRef, baseFilename, "", cooldown, c.Force) 205 if err != nil { 206 return nil, err 207 } 208 cooldown = c.Cooldown 209 210 err = c.runBenchmark("", worktreeFilename, "", cooldown, false) 211 if err != nil { 212 return nil, err 213 } 214 215 return result, nil 216 } 217 218 // Run runs the Benchdiff 219 func (c *Benchdiff) Run() (*RunResult, error) { 220 err := os.MkdirAll(c.ResultsDir, 0o700) 221 if err != nil { 222 return nil, err 223 } 224 res, err := c.runBenchmarks() 225 if err != nil { 226 return nil, err 227 } 228 collection, err := c.Benchstat.Run(res.baseOutputFile, res.worktreeOutputFile) 229 if err != nil { 230 return nil, err 231 } 232 result := &RunResult{ 233 headSHA: res.headSHA, 234 baseSHA: res.baseSHA, 235 benchCmd: res.benchmarkCmd, 236 tables: collection.Tables(), 237 } 238 return result, nil 239 } 240 241 // RunResult is the result of a Run 242 type RunResult struct { 243 headSHA string 244 baseSHA string 245 benchCmd string 246 tables []*benchstat.Table 247 } 248 249 // RunResultOutputOptions options for RunResult.WriteOutput 250 type RunResultOutputOptions struct { 251 BenchstatFormatter benchstatter.OutputFormatter // default benchstatter.TextFormatter(nil) 252 OutputFormat string // one of json or human. default: human 253 Tolerance float64 254 } 255 256 // WriteOutput outputs the result 257 func (r *RunResult) WriteOutput(w io.Writer, opts *RunResultOutputOptions) error { 258 if opts == nil { 259 opts = new(RunResultOutputOptions) 260 } 261 finalOpts := &RunResultOutputOptions{ 262 BenchstatFormatter: benchstatter.TextFormatter(nil), 263 OutputFormat: "human", 264 Tolerance: opts.Tolerance, 265 } 266 if opts.BenchstatFormatter != nil { 267 finalOpts.BenchstatFormatter = opts.BenchstatFormatter 268 } 269 270 if opts.OutputFormat != "" { 271 finalOpts.OutputFormat = opts.OutputFormat 272 } 273 274 var benchstatBuf bytes.Buffer 275 err := finalOpts.BenchstatFormatter(&benchstatBuf, r.tables) 276 if err != nil { 277 return err 278 } 279 280 switch finalOpts.OutputFormat { 281 case "human": 282 return r.writeHumanResult(w, benchstatBuf.String()) 283 case "json": 284 return r.writeJSONResult(w, benchstatBuf.String(), finalOpts.Tolerance) 285 default: 286 return fmt.Errorf("unknown OutputFormat") 287 } 288 } 289 290 func (r *RunResult) writeJSONResult(w io.Writer, benchstatResult string, tolerance float64) error { 291 type runResultJSON struct { 292 BenchCommand string `json:"bench_command,omitempty"` 293 HeadSHA string `json:"head_sha,omitempty"` 294 BaseSHA string `json:"base_sha,omitempty"` 295 DegradedResult bool `json:"degraded_result"` 296 BenchstatOutput string `json:"benchstat_output,omitempty"` 297 } 298 encoder := json.NewEncoder(w) 299 encoder.SetIndent("", " ") 300 return encoder.Encode(&runResultJSON{ 301 BenchCommand: r.benchCmd, 302 BenchstatOutput: benchstatResult, 303 HeadSHA: r.headSHA, 304 BaseSHA: r.baseSHA, 305 DegradedResult: r.HasDegradedResult(tolerance), 306 }) 307 } 308 309 func (r *RunResult) writeHumanResult(w io.Writer, benchstatResult string) error { 310 var err error 311 _, err = fmt.Fprintf(w, "bench command:\n %s\n", r.benchCmd) 312 if err != nil { 313 return err 314 } 315 _, err = fmt.Fprintf(w, "HEAD sha:\n %s\n", r.headSHA) 316 if err != nil { 317 return err 318 } 319 _, err = fmt.Fprintf(w, "base sha:\n %s\n", r.baseSHA) 320 if err != nil { 321 return err 322 } 323 _, err = fmt.Fprintf(w, "benchstat output:\n\n%s\n", benchstatResult) 324 if err != nil { 325 return err 326 } 327 328 return nil 329 } 330 331 // HasDegradedResult returns true if there are any rows with DegradingChange and PctDelta over tolerance 332 func (r *RunResult) HasDegradedResult(tolerance float64) bool { 333 return r.maxDegradedPct() > tolerance 334 } 335 336 func (r *RunResult) maxDegradedPct() float64 { 337 max := 0.0 338 for _, table := range r.tables { 339 for _, row := range table.Rows { 340 if row.Change != DegradingChange { 341 continue 342 } 343 if row.PctDelta > max { 344 max = row.PctDelta 345 } 346 } 347 } 348 return max 349 } 350 351 // BenchmarkChangeType is whether a change is an improvement or degradation 352 type BenchmarkChangeType int 353 354 // BenchmarkChangeType values 355 const ( 356 DegradingChange = -1 // represents a statistically significant degradation 357 InsignificantChange = 0 // represents no statistically significant change 358 ImprovingChange = 1 // represents a statistically significant improvement 359 )