funcbench/main.go (276 lines of code) (raw):
// Copyright 2019 The Prometheus Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"bytes"
"context"
"fmt"
"io"
"log"
"os"
"os/exec"
"os/signal"
"path"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/oklog/run"
"github.com/pkg/errors"
"golang.org/x/perf/benchstat"
"gopkg.in/alecthomas/kingpin.v2"
)
type Logger interface {
Println(v ...interface{})
}
type logger struct {
*log.Logger
verbose bool
}
func (l *logger) FatalError(err error) {
if l.verbose {
l.Fatalf("%+v", err)
}
l.Fatalf("%v", err)
}
func main() {
cfg := struct {
userTestName string
verbose bool
nocomment bool
owner string
repo string
resultsDir string
workspaceDir string
ghPR int
benchTime time.Duration
benchTimeout time.Duration
compareTarget string
benchFuncRegex string
packagePath string
enablePerflock bool
}{}
app := kingpin.New(
filepath.Base(os.Args[0]),
`Benchmark and compare your Go code between sub benchmarks or commits.
* For BenchmarkFuncName, compare current with master: ./funcbench -v master BenchmarkFuncName
* For BenchmarkFunc.*, compare current with master: ./funcbench -v master BenchmarkFunc.*
* For all benchmarks, compare current with devel: ./funcbench -v devel .* or ./funcbench -v devel
* For BenchmarkFunc.*, compare current with 6d280 commit: ./funcbench -v 6d280 BenchmarkFunc.*
* For BenchmarkFunc.*, compare between sub-benchmarks of same benchmark on current commit: ./funcbench -v . BenchmarkFunc.*
* For BenchmarkFuncName, compare pr#35 with master: ./funcbench --nocomment --github-pr="35" master BenchmarkFuncName`,
)
// Options.
app.HelpFlag.Short('h')
app.Flag("verbose", "Verbose mode. Errors includes trace and commands output are logged.").
Short('v').BoolVar(&cfg.verbose)
app.Flag("nocomment", "Disable posting of comment using the GitHub API.").
BoolVar(&cfg.nocomment)
app.Flag("owner", "A Github owner or organisation name.").
Default("prometheus").StringVar(&cfg.owner)
app.Flag("repo", "This is the repository name.").
Default("prometheus").StringVar(&cfg.repo)
app.Flag("github-pr", "GitHub PR number to pull changes from and to post benchmark results.").
IntVar(&cfg.ghPR)
app.Flag("workspace", "Directory to clone GitHub PR.").
Default("/tmp/funcbench").
StringVar(&cfg.workspaceDir)
app.Flag("result-cache", "Directory to store benchmark results.").
Default("funcbench-results").
StringVar(&cfg.resultsDir)
app.Flag("user-test-name", "Name of the test to keep track of multiple benchmarks").
Default("default").
Short('n').
StringVar(&cfg.userTestName)
app.Flag("bench-time", "Run enough iterations of each benchmark to take t, specified "+
"as a time.Duration. The special syntax Nx means to run the benchmark N times").
Short('t').Default("1s").DurationVar(&cfg.benchTime)
app.Flag("timeout", "Benchmark timeout specified in time.Duration format, "+
"disabled if set to 0. If a test binary runs longer than duration d, panic.").
Short('d').Default("2h").DurationVar(&cfg.benchTimeout)
app.Flag("perflock", "Enable perflock (you must have perflock installed to use this)").
Short('l').
Default("false").
BoolVar(&cfg.enablePerflock)
app.Arg("target", "Can be one of '.', tag name, branch name or commit SHA of the branch "+
"to compare against. If set to '.', branch/commit is the same as the current one; "+
"funcbench will run once and try to compare between 2 sub-benchmarks. "+
"Errors out if there are no sub-benchmarks.").
Required().StringVar(&cfg.compareTarget)
app.Arg("bench-func-regex", "Function regex to use for benchmark."+
"Supports RE2 regexp and is fully anchored, by default will run all benchmarks.").
Default(".*").
StringVar(&cfg.benchFuncRegex) // TODO (geekodour) : validate regex?
app.Arg("packagepath", "Package to run benchmark against. Eg. ./tsdb, defaults to ./...").
Default("./...").
StringVar(&cfg.packagePath)
kingpin.MustParse(app.Parse(os.Args[1:]))
logger := &logger{
// Show file line with each log.
Logger: log.New(os.Stdout, "funcbech", log.Ltime|log.Lshortfile),
verbose: cfg.verbose,
}
var g run.Group
// Main routine.
{
ctx, cancel := context.WithCancel(context.Background())
g.Add(func() error {
var (
env Environment
err error
)
// Setup Environment.
e := environment{
logger: logger,
benchFunc: cfg.benchFuncRegex,
compareTarget: cfg.compareTarget,
}
if cfg.ghPR == 0 {
// Local Mode.
env, err = newLocalEnv(e)
if err != nil {
return errors.Wrap(err, "environment create")
}
} else {
// Github Mode.
ghClient, err := newGitHubClient(ctx, cfg.owner, cfg.repo, cfg.ghPR, cfg.nocomment)
if err != nil {
return errors.Wrapf(err, "github client")
}
env, err = newGitHubEnv(ctx, e, ghClient, cfg.workspaceDir)
if err != nil {
if err := ghClient.postComment(fmt.Sprintf("%v. Could not setup environment, please check logs.", err)); err != nil {
return errors.Wrap(err, "could not post error")
}
return errors.Wrap(err, "environment create")
}
}
// ( β_β)οΎ Start benchmarking!
benchmarker := newBenchmarker(logger, env,
&commander{verbose: cfg.verbose, ctx: ctx},
cfg.benchTime, cfg.benchTimeout,
path.Join(cfg.resultsDir, cfg.userTestName),
cfg.packagePath,
cfg.enablePerflock,
)
tables, err := startBenchmark(env, benchmarker)
if err != nil {
pErr := env.PostErr(
fmt.Sprintf(
"```\n%s\n```\nError:\n```\n%s\n```",
strings.Join(benchmarker.benchmarkArgs, " "),
err.Error(),
),
)
if pErr != nil {
return errors.Wrap(pErr, "could not log error")
}
return err
}
// Post results.
// TODO (geekodour): probably post some kind of funcbench summary(?)
return env.PostResults(
tables,
fmt.Sprintf("```\n%s\n```", strings.Join(benchmarker.benchmarkArgs, " ")),
)
}, func(err error) {
cancel()
})
}
// Listen for termination signals.
{
cancel := make(chan struct{})
g.Add(func() error {
return interrupt(logger, cancel)
}, func(error) {
close(cancel)
})
}
if err := g.Run(); err != nil {
logger.FatalError(errors.Wrap(err, "running command failed"))
}
logger.Println("exiting")
}
// startBenchmark returns the comparision results.
// 1. If target is same as current ref, run sub-benchmarks and return instead (TODO).
// 2. Execute benchmark against packages in the current worktree.
// 3. Cleanup of worktree in case funcbench was run previously and checkout target worktree.
// 4. Execute benchmark against packages in the new(target) worktree.
// 5. Return compared results.
func startBenchmark(env Environment, bench *Benchmarker) ([]*benchstat.Table, error) {
wt, _ := env.Repo().Worktree()
cmpWorkTreeDir := filepath.Join(bench.scratchWorkspaceDir)
ref, err := env.Repo().Head()
if err != nil {
return nil, errors.Wrap(err, "get head")
}
// TODO move it into env? since GitHub env doesn't need this check.
if _, err := bench.c.exec("sh", "-c", "git update-index -q --ignore-submodules --refresh && git diff-files --quiet --ignore-submodules --"); err != nil {
return nil, errors.Wrap(err, "not clean worktree")
}
if env.CompareTarget() == "." {
bench.logger.Println("Assuming sub-benchmarks comparison.")
subResult, err := bench.exec(wt.Filesystem.Root(), ref.Hash())
if err != nil {
return nil, errors.Wrap(err, "execute sub-benchmark")
}
cmps, err := bench.compareSubBenchmarks(subResult)
if err != nil {
return nil, errors.Wrap(err, "comparing sub benchmarks")
}
return cmps, nil
}
// Get info about target.
targetCommit := getTargetInfo(env.Repo(), env.CompareTarget())
if targetCommit == plumbing.ZeroHash {
return nil, fmt.Errorf("cannot find target %s", env.CompareTarget())
}
bench.logger.Println("Target:", targetCommit.String(), "Current Ref:", ref.Hash().String())
if targetCommit == ref.Hash() {
return nil, fmt.Errorf("target: %s is the same as current ref %s (or is on the same commit); No changes would be expected; Aborting", targetCommit, ref.String())
}
bench.logger.Println("Assuming comparing with target (clean workdir will be checked.)")
// Execute benchmark A.
newResult, err := bench.exec(wt.Filesystem.Root(), ref.Hash())
if err != nil {
return nil, errors.Wrapf(err, "execute benchmark for A: %v", ref.Name().String())
}
// TODO move the following part before 'Execute benchmark B.' into a function Benchmarker.switchToWorkTree.
// Best effort cleanup and checkout new worktree.
if err := os.RemoveAll(cmpWorkTreeDir); err != nil {
return nil, errors.Wrapf(err, "delete worktree at %s", cmpWorkTreeDir)
}
// TODO (geekodour): switch to worktree remove once we decide not to support git<2.17
if _, err := bench.c.exec("git", "worktree", "prune"); err != nil {
return nil, errors.Wrap(err, "worktree prune")
}
bench.logger.Println("Checking out (in new workdir):", cmpWorkTreeDir, "commmit", targetCommit.String())
if _, err := bench.c.exec("git", "worktree", "add", "-f", cmpWorkTreeDir, targetCommit.String()); err != nil {
return nil, errors.Wrapf(err, "checkout %s in worktree %s", targetCommit.String(), cmpWorkTreeDir)
}
// Execute benchmark B.
oldResult, err := bench.exec(cmpWorkTreeDir, targetCommit)
if err != nil {
return nil, errors.Wrapf(err, "execute benchmark for B: %v", env.CompareTarget())
}
// Compare B vs A.
tables, err := compareBenchmarks(oldResult, newResult)
if err != nil {
return nil, errors.Wrap(err, "comparing benchmarks")
}
// Save hashes for info about benchmark.
env.SetHashStrings(targetCommit.String(), ref.Hash().String())
return tables, nil
}
func interrupt(logger Logger, cancel <-chan struct{}) error {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
select {
case s := <-c:
logger.Println("caught signal", s, "Exiting.")
return nil
case <-cancel:
return errors.New("canceled")
}
}
// getTargetInfo returns the hash of the target if found,
// otherwise returns plumbing.ZeroHash.
// NOTE: if both a branch and a tag have the same name, it always chooses the branch name.
func getTargetInfo(repo *git.Repository, target string) plumbing.Hash {
hash, err := repo.ResolveRevision(plumbing.Revision(target))
if err != nil {
return plumbing.ZeroHash
}
return *hash
}
type commander struct {
verbose bool
ctx context.Context
}
func (c *commander) exec(command ...string) (string, error) {
cmd := exec.CommandContext(c.ctx, command[0], command[1:]...)
var b bytes.Buffer
cmd.Stdout = &b
cmd.Stderr = &b
if c.verbose {
// All to stdout.
cmd.Stdout = io.MultiWriter(cmd.Stdout, os.Stdout)
cmd.Stderr = io.MultiWriter(cmd.Stdout, os.Stdout)
}
if err := cmd.Run(); err != nil {
out := b.String()
return "", errors.Errorf("error: %v; Command out: %s", err, out)
}
return b.String(), nil
}