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  }