github.com/aclements/go-misc@v0.0.0-20240129233631-2f6ede80790c/benchmany/run.go (about) 1 // Copyright 2015 The Go Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 package main 6 7 import ( 8 "bytes" 9 "flag" 10 "fmt" 11 "io" 12 "io/ioutil" 13 "log" 14 "math" 15 "math/rand" 16 "os" 17 "os/exec" 18 "path/filepath" 19 "regexp" 20 "strings" 21 "time" 22 23 "github.com/aclements/go-misc/bench" 24 "github.com/aclements/go-moremath/stats" 25 ) 26 27 // TODO: Check CPU performance governor before each benchmark. 28 29 // TODO: Support running pre-built binaries without specific hashes. 30 // This is useful for testing things that aren't yet committed or that 31 // require unusual build steps. 32 33 var run struct { 34 order string 35 metric string 36 benchFlags string 37 buildCmd string 38 iterations int 39 saveTree bool 40 timeout time.Duration 41 clean bool 42 cleanFlags string 43 44 logPath string 45 binDir string 46 } 47 48 func init() { 49 // TODO: This makes a mess of flags during testing. 50 isXBenchmark := false 51 if abs, _ := os.Getwd(); strings.HasSuffix(abs, "golang.org/x/benchmarks/bench") { 52 isXBenchmark = true 53 } 54 55 f := flag.CommandLine 56 flag.Usage = func() { 57 fmt.Fprintf(os.Stderr, "Usage: %s [flags] <revision range>\n", os.Args[0]) 58 f.PrintDefaults() 59 } 60 f.StringVar(&run.order, "order", "seq", "run benchmarks in `order`, which must be one of: seq, spread, metric") 61 f.StringVar(&run.metric, "metric", "ns/op", "for -order metric, the benchmark metric to find differences in") 62 f.StringVar(&gitDir, "C", "", "run git in `dir`") 63 defaultBenchFlags := "-test.run NONE -test.bench ." 64 if isXBenchmark { 65 defaultBenchFlags = "" 66 } 67 f.StringVar(&run.benchFlags, "benchflags", defaultBenchFlags, "pass `flags` to benchmark") 68 defaultBuildCmd := "go test -c" 69 if isXBenchmark { 70 defaultBuildCmd = "go build" 71 } 72 f.StringVar(&run.buildCmd, "buildcmd", defaultBuildCmd, "build benchmark using \"`cmd` -o <bin>\"") 73 f.IntVar(&run.iterations, "n", 5, "run each benchmark `N` times") 74 f.StringVar(&run.logPath, "o", "", "write benchmark results to `file` (default \"bench.log\" in -d directory)") 75 f.StringVar(&run.binDir, "d", ".", "write binaries to `directory`") 76 f.BoolVar(&run.saveTree, "save-tree", false, "save Go trees using gover and run benchmarks under saved trees") 77 f.DurationVar(&run.timeout, "timeout", 30*time.Minute, "time out a run after `duration`") 78 f.BoolVar(&dryRun, "dry-run", false, "print commands but do not run them") 79 f.BoolVar(&run.clean, "clean", false, "run \"git clean -f\" after every checkout") 80 f.StringVar(&run.cleanFlags, "cleanflags", "", "add `flags` to git clean command") 81 } 82 83 func doRun() { 84 if flag.NArg() < 1 { 85 flag.Usage() 86 os.Exit(2) 87 } 88 89 var pickCommit func([]*commitInfo) *commitInfo 90 switch run.order { 91 case "seq": 92 pickCommit = pickCommitSeq 93 case "spread": 94 pickCommit = pickCommitSpread 95 case "metric": 96 pickCommit = pickCommitMetric 97 default: 98 fmt.Fprintf(os.Stderr, "unknown order: %s\n", run.order) 99 flag.Usage() 100 os.Exit(2) 101 } 102 103 if run.logPath == "" { 104 run.logPath = filepath.Join(run.binDir, "bench.log") 105 } 106 107 commits := getCommits(flag.Args(), run.logPath) 108 109 // Write header block to log. 110 if len(commits) > 0 { 111 header := new(bytes.Buffer) 112 fmt.Fprintf(header, "# Run started at %s\n", time.Now()) 113 writeHeader(header) 114 fmt.Fprintf(header, "\n") 115 commits[0].writeLog(header.String()) 116 } 117 118 // Always run git from the top level of the git tree. Some 119 // commands, like git clean, care about this. 120 gitDir = trimNL(git("rev-parse", "--show-toplevel")) 121 122 status := NewStatusReporter() 123 defer status.Stop() 124 125 for { 126 doneIters, totalIters, partialCommits, doneCommits, failedCommits := runStats(commits) 127 unstartedCommits := len(commits) - (partialCommits + doneCommits + failedCommits) 128 msg := fmt.Sprintf("%d/%d runs, %d unstarted+%d partial+%d done+%d failed commits", doneIters, totalIters, unstartedCommits, partialCommits, doneCommits, failedCommits) 129 // TODO: Count builds and runs separately. 130 status.Progress(msg, float64(doneIters)/float64(totalIters)) 131 132 commit := pickCommit(commits) 133 if commit == nil { 134 break 135 } 136 runBenchmark(commit, status) 137 } 138 } 139 140 func writeHeader(w io.Writer) { 141 goos, err := exec.Command("go", "env", "GOOS").Output() 142 if err != nil { 143 log.Fatalf("error running go env GOOS: %s", err) 144 } 145 fmt.Fprintf(w, "goos: %s\n", strings.TrimSpace(string(goos))) 146 147 goarch, err := exec.Command("go", "env", "GOARCH").Output() 148 if err != nil { 149 log.Fatalf("error running go env GOARCH: %s", err) 150 } 151 fmt.Fprintf(w, "goarch: %s\n", strings.TrimSpace(string(goarch))) 152 153 kernel, err := exec.Command("uname", "-sr").Output() 154 if err != nil { 155 log.Fatalf("error running uname -sr: %s", err) 156 } 157 fmt.Fprintf(w, "uname-sr: %s\n", strings.TrimSpace(string(kernel))) 158 159 cpuinfo, err := ioutil.ReadFile("/proc/cpuinfo") 160 if err == nil { 161 subs := regexp.MustCompile(`(?m)^model name\s*:\s*(.*)`).FindSubmatch(cpuinfo) 162 if subs != nil { 163 fmt.Fprintf(w, "cpu: %s\n", string(subs[1])) 164 } 165 } 166 167 fmt.Fprintf(w, "tool: benchmany\n") 168 } 169 170 func runStats(commits []*commitInfo) (doneIters, totalIters, partialCommits, doneCommits, failedCommits int) { 171 for _, c := range commits { 172 if c.count >= run.iterations { 173 // Don't care if it failed. 174 doneIters += c.count 175 totalIters += c.count 176 } else if c.runnable() { 177 doneIters += c.count 178 totalIters += run.iterations 179 } 180 181 if c.count == run.iterations { 182 doneCommits++ 183 } else if c.runnable() { 184 if c.count != 0 { 185 partialCommits++ 186 } 187 } else { 188 failedCommits++ 189 } 190 } 191 return 192 } 193 194 // pickCommitSeq picks the next commit to run based on the most recent 195 // commit with the fewest iterations. 196 func pickCommitSeq(commits []*commitInfo) *commitInfo { 197 var minCommit *commitInfo 198 for _, commit := range commits { 199 if !commit.runnable() { 200 continue 201 } 202 if minCommit == nil || commit.count < minCommit.count { 203 minCommit = commit 204 } 205 } 206 return minCommit 207 } 208 209 // pickCommitSpread picks the next commit to run from commits using an 210 // algorithm that spreads out the runs. 211 func pickCommitSpread(commits []*commitInfo) *commitInfo { 212 // Assign weights to each commit. This is thoroughly 213 // heuristic, but it's geared toward either increasing the 214 // iteration count of commits that we have, or picking a new 215 // commit so as to spread out the commits we have. 216 weights := make([]int, len(commits)) 217 totalWeight := 0 218 219 nPartial := 0 220 for _, commit := range commits { 221 if commit.partial() { 222 nPartial++ 223 } 224 } 225 if nPartial >= len(commits)/10 { 226 // Limit the number of partially completed revisions 227 // to 10% by only choosing a partial commit in this 228 // case. 229 for i, commit := range commits { 230 if commit.partial() { 231 // Bias toward commits that are 232 // further from done. 233 weights[i] = run.iterations - commit.count 234 } 235 } 236 } else { 237 // Pick a new commit weighted by its distance from a 238 // commit that we already have. 239 240 // Find distance from left to right. 241 distance := len(commits) 242 haveAny := false 243 for i, commit := range commits { 244 if commit.count > 0 { 245 distance = 1 246 haveAny = true 247 } else if commit.runnable() { 248 distance++ 249 } 250 weights[i] = distance 251 } 252 253 // Find distance from right to left. 254 distance = len(commits) 255 for i := len(commits) - 1; i >= 0; i-- { 256 commit := commits[i] 257 if commit.count > 0 { 258 distance = 1 259 } else if commit.runnable() { 260 distance++ 261 } 262 263 if distance < weights[i] { 264 weights[i] = distance 265 } 266 } 267 268 if !haveAny { 269 // We don't have any commits. Pick one uniformly. 270 for i := range commits { 271 weights[i] = 1 272 } 273 } 274 275 // Zero non-runnable commits. 276 for i, commit := range commits { 277 if !commit.runnable() { 278 weights[i] = 0 279 } 280 } 281 } 282 283 for _, w := range weights { 284 totalWeight += w 285 } 286 if totalWeight == 0 { 287 return nil 288 } 289 290 // Pick a commit based on the weights. 291 x := rand.Intn(totalWeight) 292 cumulative := 0 293 for i, w := range weights { 294 cumulative += w 295 if cumulative > x { 296 return commits[i] 297 } 298 } 299 panic("unreachable") 300 } 301 302 func pickCommitMetric(commits []*commitInfo) *commitInfo { 303 // If there are any partial commits, finish them up. 304 for _, c := range commits { 305 if c.partial() { 306 return c 307 } 308 } 309 310 // Remove failed commits. This makes it easier to avoid 311 // picking a failed commit below. 312 ncommits := []*commitInfo{} 313 for _, c := range commits { 314 if !c.failed() { 315 ncommits = append(ncommits, c) 316 } 317 } 318 commits = ncommits 319 if len(ncommits) == 0 { 320 return nil 321 } 322 323 // Make sure we've run the most recent commit. 324 if commits[0].runnable() { 325 return commits[0] 326 } 327 328 // Make sure we've run the earliest commit. 329 if c := commits[len(commits)-1]; c.runnable() { 330 return c 331 } 332 333 // We're bounded from both sides and every commit we've run 334 // has the best stats we're going to get. Parse run.metric 335 // from the log file. 336 logf, err := os.Open(run.logPath) 337 if err != nil { 338 log.Fatal("opening benchmark log: ", err) 339 } 340 defer logf.Close() 341 bs, err := bench.Parse(logf) 342 if err != nil { 343 log.Fatal("parsing benchmark log for metrics: ", err) 344 } 345 results := make(map[string]map[string][]float64) 346 for _, b := range bs { 347 var hash string 348 if commitConfig, ok := b.Config["commit"]; !ok { 349 continue 350 } else { 351 hash = commitConfig.RawValue 352 } 353 result, ok := b.Result[run.metric] 354 if !ok { 355 continue 356 } 357 358 if results[hash] == nil { 359 results[hash] = make(map[string][]float64) 360 } 361 results[hash][b.Name] = append(results[hash][b.Name], result) 362 } 363 geomeans := make(map[string]float64) 364 for hash, benches := range results { 365 var means []float64 366 for _, results := range benches { 367 means = append(means, stats.Mean(results)) 368 } 369 geomeans[hash] = stats.GeoMean(means) 370 } 371 372 // Find the pair of commits with the biggest difference in the 373 // metric. 374 prevI := -1 375 maxDiff, maxMid := -1.0, (*commitInfo)(nil) 376 for i, c := range commits { 377 if c.count == 0 || geomeans[c.hash] == 0 { 378 continue 379 } 380 if prevI == -1 { 381 prevI = i 382 continue 383 } 384 385 if i > prevI+1 { 386 // TODO: This isn't branch-aware. We should 387 // only compare commits with an ancestry 388 // relationship. 389 diff := math.Abs(geomeans[c.hash] - geomeans[commits[prevI].hash]) 390 if diff > maxDiff { 391 maxDiff = diff 392 maxMid = commits[(prevI+i)/2] 393 } 394 } 395 prevI = i 396 } 397 return maxMid 398 } 399 400 // runBenchmark runs the benchmark at commit. It updates commit.count, 401 // commit.fails, and commit.buildFailed as appropriate and writes to 402 // the commit log to record the outcome. 403 func runBenchmark(commit *commitInfo, status *StatusReporter) { 404 // Build the benchmark if necessary. 405 binPath := filepath.Join(run.binDir, commit.binPath()) 406 if !exists(binPath) { 407 runStatus(status, commit, "building") 408 409 // Check out the appropriate commit. This is necessary 410 // even if we're using gover because the benchmark 411 // itself might have changed (e.g., bug fixes). 412 git("checkout", "-q", commit.hash) 413 414 if run.clean { 415 args := append([]string{"-f"}, strings.Fields(run.cleanFlags)...) 416 git("clean", args...) 417 } 418 419 var buildCmd []string 420 if commit.gover { 421 buildCmd = []string{"gover", "with", commit.hash} 422 } else { 423 // If this is the Go toolchain, do a full 424 // make.bash. Otherwise, we assume that go 425 // test -c will build the necessary 426 // dependencies. 427 if exists(filepath.Join(gitDir, "src", "make.bash")) { 428 cmd := exec.Command("./make.bash") 429 cmd.Dir = filepath.Join(gitDir, "src") 430 if dryRun { 431 dryPrint(cmd) 432 } else if out, err := combinedOutputTimeout(cmd); err != nil { 433 detail := indent(string(out)) + indent(err.Error()) 434 fmt.Fprintf(os.Stderr, "failed to build toolchain at %s:\n%s", commit.hash, detail) 435 commit.logFailed(true, detail) 436 return 437 } 438 if run.saveTree && doGoverSave() == nil { 439 commit.gover = true 440 } 441 } 442 // Assume build command is in $PATH. 443 // 444 // TODO: Force PATH if we built the toolchain. 445 buildCmd = []string{} 446 } 447 448 buildCmd = append(buildCmd, strings.Fields(run.buildCmd)...) 449 buildCmd = append(buildCmd, "-o", binPath) 450 cmd := exec.Command(buildCmd[0], buildCmd[1:]...) 451 if dryRun { 452 dryPrint(cmd) 453 } else if out, err := combinedOutputTimeout(cmd); err != nil { 454 detail := indent(string(out)) + indent(err.Error()) 455 fmt.Fprintf(os.Stderr, "failed to build tests at %s:\n%s", commit.hash, detail) 456 commit.logFailed(true, detail) 457 return 458 } 459 } 460 461 // Run the benchmark. 462 runStatus(status, commit, "running") 463 if filepath.Base(binPath) == binPath { 464 // Make exec.Command treat this as a relative path. 465 binPath = "./" + binPath 466 } 467 args := append([]string{binPath}, strings.Fields(run.benchFlags)...) 468 if run.saveTree { 469 args = append([]string{"gover", "with", commit.hash}, args...) 470 } 471 cmd := exec.Command(args[0], args[1:]...) 472 if dryRun { 473 dryPrint(cmd) 474 commit.count++ 475 return 476 } 477 out, err := combinedOutputTimeout(cmd) 478 if err == nil { 479 commit.logRun(string(out)) 480 } else { 481 detail := indent(string(out)) + indent(err.Error()) 482 fmt.Fprintf(os.Stderr, "failed to run benchmark at %s:\n%s", commit.hash, detail) 483 commit.logFailed(false, detail) 484 } 485 } 486 487 func doGoverSave() error { 488 cmd := exec.Command("gover", "save") 489 cmd.Dir = gitDir 490 if dryRun { 491 dryPrint(cmd) 492 return nil 493 } else { 494 out, err := cmd.CombinedOutput() 495 if err != nil { 496 fmt.Fprintf(os.Stderr, "gover save failed: %s:\n%s", err, indent(string(out))) 497 } 498 return err 499 } 500 } 501 502 // runStatus updates the status message for commit. 503 func runStatus(sr *StatusReporter, commit *commitInfo, status string) { 504 sr.Message(fmt.Sprintf("commit %s, iteration %d/%d: %s...", commit.hash[:7], commit.count+1, run.iterations, status)) 505 } 506 507 // combinedOutputTimeout is like c.CombinedOutput(), but if 508 // run.timeout != 0, it will kill c after run.timeout time expires. 509 func combinedOutputTimeout(c *exec.Cmd) (out []byte, err error) { 510 var b bytes.Buffer 511 c.Stdout = &b 512 c.Stderr = &b 513 if err := c.Start(); err != nil { 514 return nil, err 515 } 516 517 if run.timeout == 0 { 518 err := c.Wait() 519 return b.Bytes(), err 520 } 521 522 tick := time.NewTimer(run.timeout) 523 trace := signalTrace 524 done := make(chan error) 525 go func() { 526 done <- c.Wait() 527 }() 528 loop: 529 for { 530 select { 531 case err = <-done: 532 break loop 533 case <-tick.C: 534 if trace != nil { 535 fmt.Fprintf(os.Stderr, "command timed out; sending %v\n", trace) 536 c.Process.Signal(trace) 537 tick = time.NewTimer(5 * time.Second) 538 trace = nil 539 } else { 540 fmt.Fprintf(os.Stderr, "command timed out; killing\n") 541 c.Process.Kill() 542 } 543 } 544 } 545 tick.Stop() 546 return b.Bytes(), err 547 }