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  )