| // Copyright 2022 The Dawn & Tint Authors |
| // |
| // Redistribution and use in source and binary forms, with or without |
| // modification, are permitted provided that the following conditions are met: |
| // |
| // 1. Redistributions of source code must retain the above copyright notice, this |
| // list of conditions and the following disclaimer. |
| // |
| // 2. Redistributions in binary form must reproduce the above copyright notice, |
| // this list of conditions and the following disclaimer in the documentation |
| // and/or other materials provided with the distribution. |
| // |
| // 3. Neither the name of the copyright holder nor the names of its |
| // contributors may be used to endorse or promote products derived from |
| // this software without specific prior written permission. |
| // |
| // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" |
| // AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE |
| // IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE |
| // DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE |
| // FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL |
| // DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR |
| // SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER |
| // CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, |
| // OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE |
| // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| |
| package main |
| |
| import ( |
| "context" |
| "crypto/sha256" |
| "encoding/hex" |
| "encoding/json" |
| "errors" |
| "flag" |
| "fmt" |
| "io" |
| "log" |
| "math" |
| "os" |
| "os/exec" |
| "path/filepath" |
| "reflect" |
| "regexp" |
| "sort" |
| "strings" |
| "time" |
| |
| "dawn.googlesource.com/dawn/tools/src/bench" |
| "dawn.googlesource.com/dawn/tools/src/git" |
| "github.com/andygrunwald/go-gerrit" |
| "github.com/shirou/gopsutil/cpu" |
| ) |
| |
| // main entry point |
| func main() { |
| var cfgPath string |
| flag.StringVar(&cfgPath, "c", "~/.config/perfmon/config.json", "the config file") |
| flag.Parse() |
| |
| if err := run(cfgPath); err != nil { |
| fmt.Fprintln(os.Stderr, err) |
| os.Exit(1) |
| } |
| } |
| |
| // run starts the perfmon tool with the given config path |
| func run(cfgPath string) error { |
| cfgPath, err := expandHomeDir(cfgPath) |
| if err != nil { |
| return err |
| } |
| |
| if err := findTools(); err != nil { |
| return err |
| } |
| |
| g, err := git.New(tools.git) |
| if err != nil { |
| return err |
| } |
| |
| cfg, err := loadConfig(cfgPath) |
| if err != nil { |
| return err |
| } |
| |
| dawnDir, resultsDir, err := makeWorkingDirs(cfg) |
| if err != nil { |
| return err |
| } |
| dawnRepo, err := createOrOpenGitRepo(g, dawnDir, cfg.Dawn) |
| if err != nil { |
| return err |
| } |
| resultsRepo, err := createOrOpenGitRepo(g, resultsDir, cfg.Results) |
| if err != nil { |
| return err |
| } |
| gerritClient, err := gerrit.NewClient(cfg.Gerrit.URL, nil) |
| if err != nil { |
| return err |
| } |
| gerritClient.Authentication.SetBasicAuth(cfg.Gerrit.Username, cfg.Gerrit.Password) |
| |
| sysInfo, err := cpu.Info() |
| if err != nil { |
| return fmt.Errorf("failed to obtain system info:\n %v", err) |
| } |
| |
| // Some machines report slightly different CPU clock speeds each reboot |
| // To work around this, quantize the reported speed to the nearest 100MHz |
| for i, s := range sysInfo { |
| sysInfo[i].Mhz = math.Round(s.Mhz/100) * 100 |
| } |
| |
| e := env{ |
| cfg: cfg, |
| git: g, |
| system: sysInfo, |
| systemID: hash(sysInfo)[:8], |
| dawnDir: dawnDir, |
| buildDir: filepath.Join(dawnDir, "out"), |
| resultsDir: resultsDir, |
| dawnRepo: dawnRepo, |
| resultsRepo: resultsRepo, |
| gerrit: gerritClient, |
| |
| benchmarkCache: map[git.Hash]*bench.Run{}, |
| } |
| |
| for { |
| didSomething, err := e.doSomeWork() |
| if err != nil { |
| log.Printf("ERROR: %v", err) |
| log.Printf("Pausing...") |
| time.Sleep(time.Minute * 10) |
| continue |
| } |
| if !didSomething { |
| log.Println("nothing to do. Sleeping...") |
| time.Sleep(time.Minute * 5) |
| } |
| } |
| } |
| |
| // Config holds the root configuration options for the perfmon tool |
| type Config struct { |
| WorkingDir string |
| RootChange git.Hash |
| Dawn GitConfig |
| Results GitConfig |
| Gerrit GerritConfig |
| Timeouts TimeoutsConfig |
| ExternalAccounts []string |
| BenchmarkRepetitions int |
| BenchmarkMaxTemp float32 // celsius |
| CPUTempSensorName string // Name of the sensor to use for CPU temp |
| ExternalBenchmarkCorpus string |
| } |
| |
| // GitConfig holds the configuration options for accessing a git repo |
| type GitConfig struct { |
| URL string |
| Branch string |
| Credentials git.Credentials |
| } |
| |
| // GerritConfig holds the configuration options for accessing gerrit |
| type GerritConfig struct { |
| URL string |
| Username string |
| Email string |
| Password string |
| } |
| |
| // TimeoutsConfig holds the configuration options for timeouts |
| type TimeoutsConfig struct { |
| Sync time.Duration |
| Build time.Duration |
| Benchmark time.Duration |
| } |
| |
| // HistoricResults contains the full set of historic benchmark results for a |
| // given system |
| type HistoricResults struct { |
| System []cpu.InfoStat |
| Commits []CommitResults |
| } |
| |
| // CommitResults holds the results of a single dawn commit |
| type CommitResults struct { |
| Commit string |
| CommitTime time.Time |
| CommitDescription string |
| Benchmarks []Benchmark |
| } |
| |
| // Benchmark holds the benchmark results for a single test |
| type Benchmark struct { |
| Name string |
| Time float64 |
| Repeats int `json:",omitempty"` |
| } |
| |
| // AuthConfig holds the authentication options for accessing a git repo |
| type AuthConfig struct { |
| Username string |
| Password string |
| } |
| |
| // setDefaults assigns default values to unassigned fields of cfg |
| func (cfg *Config) setDefaults() { |
| if cfg.RootChange.IsZero() { |
| cfg.RootChange, _ = git.ParseHash("e72e42d9e0c851311512ca6da4d7b59f0bcc60d9") |
| } |
| cfg.Dawn.setDefaults() |
| cfg.Results.setDefaults() |
| cfg.Timeouts.setDefaults() |
| if cfg.BenchmarkRepetitions < 1 { |
| cfg.BenchmarkRepetitions = 1 |
| } |
| if cfg.BenchmarkMaxTemp == 0 { |
| cfg.BenchmarkMaxTemp = 50 |
| } |
| } |
| |
| // setDefaults assigns default values to unassigned fields of cfg |
| func (cfg *GitConfig) setDefaults() { |
| if cfg.Branch == "" { |
| cfg.Branch = "main" |
| } |
| } |
| |
| // setDefaults assigns default values to unassigned fields of cfg |
| func (cfg *TimeoutsConfig) setDefaults() { |
| if cfg.Sync == 0 { |
| cfg.Sync = time.Minute * 30 |
| } |
| if cfg.Build == 0 { |
| cfg.Build = time.Minute * 30 |
| } |
| if cfg.Benchmark == 0 { |
| cfg.Benchmark = time.Minute * 30 |
| } |
| } |
| |
| // findCommitResults looks for a CommitResult with the given commit id, |
| // returning a pointer to the CommitResult if found, otherwise nil |
| func (h *HistoricResults) findCommitResults(commit string) *CommitResults { |
| for i, c := range h.Commits { |
| if c.Commit == commit { |
| return &h.Commits[i] |
| } |
| } |
| return nil |
| } |
| |
| // sorts all the benchmarks by commit date |
| func (h *HistoricResults) sort() { |
| sort.Slice(h.Commits, func(i, j int) bool { |
| if h.Commits[i].CommitTime.Before(h.Commits[j].CommitTime) { |
| return true |
| } |
| if h.Commits[j].CommitTime.Before(h.Commits[i].CommitTime) { |
| return false |
| } |
| return h.Commits[i].CommitDescription < h.Commits[j].CommitDescription |
| }) |
| } |
| |
| // findBenchmark looks for a Benchmark with the given commit id, |
| // returning a pointer to the Benchmark if found, otherwise nil |
| func (r *CommitResults) findBenchmark(name string) *Benchmark { |
| for i, b := range r.Benchmarks { |
| if b.Name == name { |
| return &r.Benchmarks[i] |
| } |
| } |
| return nil |
| } |
| |
| // sorts all the benchmarks by name |
| func (r *CommitResults) sort() { |
| sort.Slice(r.Benchmarks, func(i, j int) bool { |
| return r.Benchmarks[i].Name < r.Benchmarks[j].Name |
| }) |
| } |
| |
| // env holds the perfmon main environment state |
| type env struct { |
| cfg Config |
| git *git.Git |
| system []cpu.InfoStat |
| systemID string |
| dawnDir string |
| buildDir string |
| resultsDir string |
| dawnRepo *git.Repository |
| resultsRepo *git.Repository |
| gerrit *gerrit.Client |
| |
| benchmarkCache map[git.Hash]*bench.Run |
| } |
| |
| // doSomeWork scans gerrit for changes up for review and submitted changes to |
| // benchmark. If something was found to do, then returns true. |
| func (e env) doSomeWork() (bool, error) { |
| { |
| log.Println("scanning for review changes to benchmark...") |
| change, err := e.findGerritChangeToBenchmark() |
| if err != nil { |
| return true, err |
| } |
| if change != nil { |
| if err := e.benchmarkGerritChange(*change); err != nil { |
| return true, err |
| } |
| return true, nil |
| } |
| } |
| |
| { |
| log.Println("scanning for submitted changes to benchmark...") |
| changesToBenchmark, err := e.changesToBenchmark() |
| if err != nil { |
| return true, err |
| } |
| |
| if len(changesToBenchmark) > 0 { |
| log.Printf("%v submitted changes to benchmark...", len(changesToBenchmark)) |
| |
| start := time.Now() |
| for i, c := range changesToBenchmark { |
| if time.Since(start) > time.Minute*15 { |
| // It's been a while since we scanned for review changes. |
| // Take a break from benchmarking submitted changes so we |
| // can scan for review changes to benchmark. |
| log.Printf("benchmarked %v changes", i) |
| return true, nil |
| } |
| benchRes, err := e.benchmarkTintChange(c.hash, c.desc) |
| if err != nil { |
| log.Printf("benchmarking failed: %v", err) |
| benchRes = &bench.Run{} |
| } |
| commitRes, err := e.benchmarksToCommitResults(c.hash, *benchRes) |
| if err != nil { |
| return true, err |
| } |
| log.Printf("pushing results...") |
| if err := e.pushUpdatedResults(*commitRes); err != nil { |
| return true, err |
| } |
| } |
| return true, nil |
| } |
| } |
| |
| { |
| log.Println("scanning for benchmarks to refine...") |
| changeToBenchmark, err := e.changeToRefineBenchmarks() |
| if err != nil { |
| return true, err |
| } |
| |
| if changeToBenchmark != nil { |
| log.Printf("re-benchmarking change '%v'", changeToBenchmark.hash) |
| benchRes, err := e.benchmarkTintChange(changeToBenchmark.hash, changeToBenchmark.desc) |
| if err != nil { |
| log.Printf("benchmarking failed: %v", err) |
| benchRes = &bench.Run{} |
| } |
| commitRes, err := e.benchmarksToCommitResults(changeToBenchmark.hash, *benchRes) |
| if err != nil { |
| return true, err |
| } |
| log.Printf("pushing results...") |
| if err := e.pushUpdatedResults(*commitRes); err != nil { |
| return true, err |
| } |
| return true, nil |
| } |
| } |
| return false, nil |
| } |
| |
| // HashAndDesc describes a single change to benchmark |
| type HashAndDesc struct { |
| hash git.Hash |
| desc string |
| } |
| |
| // changesToBenchmark fetches the list of changes that do not currently have |
| // benchmark results, which should be benchmarked. |
| func (e env) changesToBenchmark() ([]HashAndDesc, error) { |
| log.Println("syncing dawn repo...") |
| latest, err := e.dawnRepo.Fetch(e.cfg.Dawn.Branch, &git.FetchOptions{ |
| Credentials: e.cfg.Dawn.Credentials, |
| }) |
| if err != nil { |
| return nil, err |
| } |
| allChanges, err := e.dawnRepo.Log(&git.LogOptions{ |
| From: e.cfg.RootChange.String(), |
| To: latest.String(), |
| }) |
| if err != nil { |
| return nil, fmt.Errorf("failed to obtain dawn log:\n %w", err) |
| } |
| log.Println(len(allChanges), "changes between", e.cfg.RootChange.String(), "and", latest.String()) |
| changesWithBenchmarks, err := e.changesWithBenchmarks() |
| if err != nil { |
| return nil, fmt.Errorf("failed to gather changes with existing benchmarks:\n %w", err) |
| } |
| log.Println(len(changesWithBenchmarks), "changes with existing benchmarks") |
| |
| changesToBenchmark := make([]HashAndDesc, 0, len(allChanges)) |
| for _, c := range allChanges { |
| if _, exists := changesWithBenchmarks[c.Hash]; !exists { |
| changesToBenchmark = append(changesToBenchmark, HashAndDesc{c.Hash, c.Subject}) |
| } |
| } |
| |
| return changesToBenchmark, nil |
| } |
| |
| // changeToRefineBenchmarks scans for the most suitable historic commit to |
| // re-benchmark and refine the results. Returns nil if there are no suitable |
| // changes. |
| func (e env) changeToRefineBenchmarks() (*HashAndDesc, error) { |
| log.Println("syncing results repo...") |
| if err := fetchAndCheckoutLatest(e.resultsRepo, e.cfg.Results); err != nil { |
| return nil, err |
| } |
| |
| _, absPath, err := e.resultsFilePaths() |
| if err != nil { |
| return nil, err |
| } |
| |
| results, err := e.loadHistoricResults(absPath) |
| if err != nil { |
| log.Println(fmt.Errorf("WARNING: failed to open result file '%v':\n %w", absPath, err)) |
| return nil, nil |
| } |
| |
| if len(results.Commits) == 0 { |
| return nil, nil |
| } |
| |
| type changeDelta struct { |
| change HashAndDesc |
| delta float64 |
| } |
| hashDeltas := make([]changeDelta, 0, len(results.Commits)) |
| for i, c := range results.Commits { |
| hash, err := git.ParseHash(c.Commit) |
| if err != nil { |
| return nil, err |
| } |
| |
| prev := results.Commits[max(0, i-1)] |
| next := results.Commits[min(len(results.Commits)-1, i+1)] |
| delta, count := 0.0, 0 |
| for _, b := range c.Benchmarks { |
| if b.Time == 0 { |
| continue |
| } |
| p, n := b.Time, b.Time |
| if pb := prev.findBenchmark(b.Name); pb != nil { |
| p = pb.Time |
| } |
| if nb := next.findBenchmark(b.Name); nb != nil { |
| n = nb.Time |
| } |
| avr := (p + n) / 2 |
| confidence := math.Pow(2, float64(b.Repeats)) |
| delta += math.Abs(avr-b.Time) / (b.Time * confidence) |
| count++ |
| } |
| if count > 0 { |
| delta = delta / float64(count) |
| desc := strings.Split(c.CommitDescription, "\n")[0] |
| hashDeltas = append(hashDeltas, changeDelta{HashAndDesc{hash, desc}, delta}) |
| } |
| } |
| |
| sort.Slice(hashDeltas, func(i, j int) bool { return hashDeltas[i].delta > hashDeltas[j].delta }) |
| |
| return &hashDeltas[0].change, nil |
| } |
| |
| // benchmarkTintChangeIfNotCached first checks the results cache for existing |
| // benchmark values for the given change, returning those cached values if hit. |
| // If the cache does not contain results for the change, then |
| // e.benchmarkTintChange() is called. |
| func (e env) benchmarkTintChangeIfNotCached(hash git.Hash, desc string) (*bench.Run, error) { |
| if cached, ok := e.benchmarkCache[hash]; ok { |
| log.Printf("reusing cached benchmark results of '%v'...", hash) |
| return cached, nil |
| } |
| return e.benchmarkTintChange(hash, desc) |
| } |
| |
| // benchmarkTintChange checks out the given commit, fetches the dawn third party |
| // dependencies, builds tint, then runs the benchmarks, returning the results. |
| func (e env) benchmarkTintChange(hash git.Hash, desc string) (*bench.Run, error) { |
| log.Printf("checking out dawn at '%v': %v...", hash, desc) |
| if err := checkout(hash, e.dawnRepo); err != nil { |
| return nil, err |
| } |
| log.Println("fetching dawn dependencies...") |
| if err := e.fetchDawnDeps(); err != nil { |
| return nil, err |
| } |
| log.Println("building tint...") |
| if err := e.buildTint(); err != nil { |
| return nil, err |
| } |
| if err := e.waitForTempsToSettle(); err != nil { |
| return nil, err |
| } |
| log.Println("benchmarking tint...") |
| run, err := e.repeatedlyBenchmarkTint() |
| if err != nil { |
| return nil, err |
| } |
| |
| e.benchmarkCache[hash] = run |
| return run, nil |
| } |
| |
| // benchmarksToCommitResults converts the benchmarks in the provided bench.Run |
| // to a CommitResults. |
| func (e env) benchmarksToCommitResults(hash git.Hash, results bench.Run) (*CommitResults, error) { |
| commits, err := e.dawnRepo.Log(&git.LogOptions{ |
| From: hash.String(), |
| }) |
| if err != nil || len(commits) == 0 { |
| return nil, fmt.Errorf("failed to get commit object '%v' of dawn repo:\n %w", hash, err) |
| } |
| commit := commits[len(commits)-1] |
| if commit.Hash != hash { |
| panic(fmt.Errorf("git.Repository.Log({From: %v}) returned:\n%+v", hash, commits)) |
| } |
| |
| m := map[string]Benchmark{} |
| for _, b := range results.Benchmarks { |
| m[b.Name] = Benchmark{ |
| Name: b.Name, |
| Time: float64(b.Duration) / float64(time.Second), |
| } |
| } |
| |
| out := &CommitResults{ |
| Commit: commit.Hash.String(), |
| CommitDescription: commit.Subject, |
| CommitTime: commit.Date, |
| Benchmarks: make([]Benchmark, 0, len(m)), |
| } |
| for _, b := range m { |
| out.Benchmarks = append(out.Benchmarks, b) |
| } |
| out.sort() |
| |
| return out, nil |
| } |
| |
| // changesWithBenchmarks returns a set of dawn changes that we already have |
| // benchmarks for. |
| func (e env) changesWithBenchmarks() (map[git.Hash]struct{}, error) { |
| log.Println("syncing results repo...") |
| if err := fetchAndCheckoutLatest(e.resultsRepo, e.cfg.Results); err != nil { |
| return nil, err |
| } |
| |
| _, absPath, err := e.resultsFilePaths() |
| if err != nil { |
| return nil, err |
| } |
| |
| results, err := e.loadHistoricResults(absPath) |
| if err != nil { |
| log.Println(fmt.Errorf("WARNING: failed to open result file '%v':\n %w", absPath, err)) |
| return nil, nil |
| } |
| |
| m := make(map[git.Hash]struct{}, len(results.Commits)) |
| for _, c := range results.Commits { |
| hash, err := git.ParseHash(c.Commit) |
| if err != nil { |
| return nil, err |
| } |
| m[hash] = struct{}{} |
| } |
| return m, nil |
| } |
| |
| // pushUpdatedResults fetches and loads the latest benchmark results, adds or |
| // merges the new results 'res' to the file, and then pushes the new results to |
| // the server. |
| func (e env) pushUpdatedResults(res CommitResults) error { |
| log.Println("syncing results repo...") |
| if err := fetchAndCheckoutLatest(e.resultsRepo, e.cfg.Results); err != nil { |
| return err |
| } |
| |
| relPath, absPath, err := e.resultsFilePaths() |
| if err != nil { |
| return err |
| } |
| |
| h, err := e.loadHistoricResults(absPath) |
| if err != nil { |
| log.Println(fmt.Errorf("failed to open result file '%v'. Creating new file\n %w", absPath, err)) |
| h = &HistoricResults{System: e.system} |
| } |
| |
| // Are there existing benchmark results for this commit? |
| if existing := h.findCommitResults(res.Commit); existing != nil { |
| // Yes: merge in the new results |
| for _, b := range res.Benchmarks { |
| if e := existing.findBenchmark(b.Name); e != nil { |
| // Benchmark found to merge. Add a weighted contribution to the benchmark value. |
| e.Time = (e.Time*float64(e.Repeats+1) + b.Time) / float64(e.Repeats+2) |
| e.Repeats++ |
| } else { |
| // New benchmark? Just append. |
| existing.Benchmarks = append(existing.Benchmarks, b) |
| } |
| } |
| existing.sort() |
| } else { |
| // New benchmark results for this commit. Just append. |
| h.Commits = append(h.Commits, res) |
| } |
| |
| // Sort the commits by timestamp |
| h.sort() |
| |
| // Write the new results to the file |
| f, err := os.Create(absPath) |
| if err != nil { |
| return fmt.Errorf("failed to create updated results file '%v':\n %w", absPath, err) |
| } |
| defer f.Close() |
| |
| enc := json.NewEncoder(f) |
| enc.SetIndent("", " ") |
| if err := enc.Encode(h); err != nil { |
| return fmt.Errorf("failed to encode updated results file '%v':\n %w", absPath, err) |
| } |
| |
| // Stage the file |
| if err := e.resultsRepo.Add(relPath, nil); err != nil { |
| return fmt.Errorf("failed to stage updated results file '%v':\n %w", relPath, err) |
| } |
| |
| // Commit the change |
| msg := fmt.Sprintf("Add benchmark results for '%v'", res.Commit[:6]) |
| hash, err := e.resultsRepo.Commit(msg, &git.CommitOptions{ |
| AuthorName: "tint perfmon bot", |
| AuthorEmail: "tint-perfmon-bot@gmail.com", |
| }) |
| if err != nil { |
| return fmt.Errorf("failed to commit updated results file '%v':\n %w", absPath, err) |
| } |
| |
| // Push the change |
| log.Println("pushing updated results to results repo...") |
| if err := e.resultsRepo.Push(hash.String(), e.cfg.Results.Branch, &git.PushOptions{ |
| Credentials: e.cfg.Results.Credentials, |
| }); err != nil { |
| return fmt.Errorf("failed to push updated results file '%v':\n %w", absPath, err) |
| } |
| |
| return nil |
| } |
| |
| // resultsFilePaths returns the paths to the results.json file, holding the |
| // benchmarks for the given system. |
| func (e env) resultsFilePaths() (relPath string, absPath string, err error) { |
| dir := filepath.Join(e.resultsDir, "results") |
| if err = os.MkdirAll(dir, 0777); err != nil { |
| err = fmt.Errorf("failed to create results directory '%v':\n %w", dir, err) |
| return |
| } |
| relPath = filepath.Join("results", e.systemID+".json") |
| absPath = filepath.Join(dir, e.systemID+".json") |
| return |
| } |
| |
| // loadHistoricResults loads and returns the results.json file for the given |
| // system. |
| func (e env) loadHistoricResults(path string) (*HistoricResults, error) { |
| file, err := os.Open(path) |
| if err != nil { |
| return nil, fmt.Errorf("failed to open result file '%v':\n %w", path, err) |
| } |
| defer file.Close() |
| res := &HistoricResults{} |
| if err := json.NewDecoder(file).Decode(res); err != nil { |
| return nil, fmt.Errorf("failed to parse result file '%v':\n %w", path, err) |
| } |
| |
| if !reflect.DeepEqual(res.System, e.system) { |
| log.Printf(`WARNING: results file '%v' has different system information! |
| File: %+v |
| System: %+v`, path, res.System, e.system) |
| } |
| |
| return res, nil |
| } |
| |
| // fetchDawnDeps fetches the third party dawn dependencies using gclient. |
| func (e env) fetchDawnDeps() error { |
| gclientConfig := filepath.Join(e.dawnDir, ".gclient") |
| if _, err := os.Stat(gclientConfig); errors.Is(err, os.ErrNotExist) { |
| standalone := filepath.Join(e.dawnDir, "scripts", "standalone.gclient") |
| if err := copyFile(gclientConfig, standalone); err != nil { |
| return fmt.Errorf("failed to copy '%v' to '%v':\n %w", standalone, gclientConfig, err) |
| } |
| } |
| if _, err := call(tools.gclient, e.dawnDir, e.cfg.Timeouts.Sync, |
| "sync", |
| "--force", |
| ); err != nil { |
| return errFailedToBuild{reason: fmt.Errorf("failed to fetch dawn dependencies:\n %w", err)} |
| } |
| return nil |
| } |
| |
| // buildTint builds the tint benchmarks. |
| func (e env) buildTint() error { |
| if err := os.MkdirAll(e.buildDir, 0777); err != nil { |
| return fmt.Errorf("failed to create build directory at '%v':\n %w", e.buildDir, err) |
| } |
| |
| // Delete any existing tint benchmark executables to ensure we're not using a stale binary |
| os.Remove(filepath.Join(e.buildDir, "tint_benchmark")) |
| os.Remove(filepath.Join(e.buildDir, "tint-benchmark")) |
| |
| if _, err := call(tools.cmake, e.buildDir, e.cfg.Timeouts.Build, |
| e.dawnDir, |
| "-GNinja", |
| "-DCMAKE_CXX_COMPILER_LAUNCHER=ccache", |
| "-DCMAKE_BUILD_TYPE=Release", |
| "-DCMAKE_BUILD_TESTS=0", |
| "-DCMAKE_BUILD_SAMPLES=0", |
| "-DTINT_EXTERNAL_BENCHMARK_CORPUS_DIR="+e.cfg.ExternalBenchmarkCorpus, |
| "-DTINT_BUILD_DOCS=0", |
| "-DTINT_BUILD_CMD_TOOLS=0", |
| "-DTINT_BUILD_TESTS=0", |
| "-DTINT_BUILD_SPV_READER=1", |
| "-DTINT_BUILD_WGSL_READER=1", |
| "-DTINT_BUILD_GLSL_WRITER=1", |
| "-DTINT_BUILD_HLSL_WRITER=1", |
| "-DTINT_BUILD_MSL_WRITER=1", |
| "-DTINT_BUILD_SPV_WRITER=1", |
| "-DTINT_BUILD_WGSL_WRITER=1", |
| "-DTINT_BUILD_BENCHMARKS=1", |
| "-DDAWN_BUILD_CMD_TOOLS=0", |
| ); err != nil { |
| return errFailedToBuild{fmt.Errorf("failed to generate dawn build config:\n %w", err)} |
| } |
| if _, err := call(tools.ninja, e.buildDir, e.cfg.Timeouts.Build); err != nil { |
| return errFailedToBuild{err} |
| } |
| return nil |
| } |
| |
| // errFailedToBuild is the error returned by buildTint() if the build failed |
| type errFailedToBuild struct { |
| // The reason |
| reason error |
| } |
| |
| func (e errFailedToBuild) Error() string { |
| return fmt.Sprintf("failed to build: %v", e.reason) |
| } |
| |
| // errFailedToBenchmark is the error returned by benchmarkTint() if the benchmark failed |
| type errFailedToBenchmark struct { |
| // The reason |
| reason error |
| } |
| |
| func (e errFailedToBenchmark) Error() string { |
| return fmt.Sprintf("failed to benchmark: %v", e.reason) |
| } |
| |
| // benchmarkTint runs the tint benchmarks e.cfg.BenchmarkRepetitions times, |
| // returning the median timing. |
| func (e env) repeatedlyBenchmarkTint() (*bench.Run, error) { |
| var ctx *bench.Context |
| testTimes := map[string][]time.Duration{} |
| for i := 0; i < e.cfg.BenchmarkRepetitions; i++ { |
| if err := e.waitForTempsToSettle(); err != nil { |
| return nil, err |
| } |
| log.Printf("benchmark pass %v/%v...", (i + 1), e.cfg.BenchmarkRepetitions) |
| run, err := e.benchmarkTint() |
| if err != nil { |
| return nil, err |
| } |
| for _, b := range run.Benchmarks { |
| testTimes[b.Name] = append(testTimes[b.Name], b.Duration) |
| } |
| if ctx == nil { |
| ctx = run.Context |
| } |
| } |
| |
| out := bench.Run{Context: ctx} |
| for name, times := range testTimes { |
| sort.Slice(times, func(i, j int) bool { return times[i] < times[j] }) |
| out.Benchmarks = append(out.Benchmarks, bench.Benchmark{ |
| Name: name, |
| Duration: times[len(times)/2], // Median |
| }) |
| } |
| |
| return &out, nil |
| } |
| |
| // benchmarkTint runs the tint benchmarks once, returning the results. |
| func (e env) benchmarkTint() (*bench.Run, error) { |
| exe := filepath.Join(e.buildDir, "tint_benchmark") |
| if _, err := os.Stat(exe); err != nil { |
| exe = filepath.Join(e.buildDir, "tint-benchmark") |
| } |
| if _, err := os.Stat(exe); err != nil { |
| return nil, fmt.Errorf("failed to find tint benchmark executable") |
| } |
| |
| out, err := call(exe, e.buildDir, e.cfg.Timeouts.Benchmark, |
| "--benchmark_format=json", |
| "--benchmark_enable_random_interleaving=true", |
| ) |
| if err != nil { |
| return nil, errFailedToBenchmark{fmt.Errorf("failed to run benchmarks: %w\noutput: %v", err, out)} |
| } |
| |
| results, err := bench.Parse(out) |
| if err != nil { |
| return nil, errFailedToBenchmark{fmt.Errorf("failed to parse benchmark results: %w\noutput: %v", err, out)} |
| } |
| return &results, nil |
| } |
| |
| // findGerritChangeToBenchmark queries gerrit for a change to benchmark. |
| func (e env) findGerritChangeToBenchmark() (*gerrit.ChangeInfo, error) { |
| log.Println("querying gerrit for changes...") |
| results, _, err := e.gerrit.Changes.QueryChanges(&gerrit.QueryChangeOptions{ |
| QueryOptions: gerrit.QueryOptions{ |
| Query: []string{"project:dawn status:open+-age:3d"}, |
| Limit: 100, |
| }, |
| ChangeOptions: gerrit.ChangeOptions{ |
| AdditionalFields: []string{"CURRENT_REVISION", "CURRENT_COMMIT", "MESSAGES", "LABELS", "DETAILED_ACCOUNTS"}, |
| }, |
| }) |
| if err != nil { |
| return nil, fmt.Errorf("failed to get list of changes:\n %w", err) |
| } |
| |
| type candidate struct { |
| change gerrit.ChangeInfo |
| priority int |
| } |
| |
| candidates := make([]candidate, 0, len(*results)) |
| |
| for _, change := range *results { |
| kokoroApproved := change.Labels["Kokoro"].Approved.AccountID != 0 |
| codeReviewScore := change.Labels["Code-Review"].Value |
| codeReviewApproved := change.Labels["Code-Review"].Approved.AccountID != 0 |
| presubmitReady := change.Labels["Presubmit-Ready"].Approved.AccountID != 0 |
| verifiedScore := change.Labels["Verified"].Value |
| |
| current, ok := change.Revisions[change.CurrentRevision] |
| if !ok { |
| log.Printf("WARNING: couldn't find current revision for change '%s'", change.ChangeID) |
| } |
| |
| canBenchmark := func() bool { |
| // Don't benchmark changes on non-main branches |
| if change.Branch != "main" { |
| return false |
| } |
| |
| // Is the change from a Googler, reviewed by a Googler or is from a allow-listed external developer? |
| if !(strings.HasSuffix(current.Commit.Committer.Email, "@google.com") || |
| strings.HasSuffix(change.Labels["Code-Review"].Approved.Email, "@google.com") || |
| strings.HasSuffix(change.Labels["Code-Review"].Recommended.Email, "@google.com") || |
| strings.HasSuffix(change.Labels["Presubmit-Ready"].Approved.Email, "@google.com")) { |
| permitted := false |
| for _, email := range e.cfg.ExternalAccounts { |
| if strings.EqualFold(current.Commit.Committer.Email, email) { |
| permitted = true |
| break |
| } |
| |
| } |
| if !permitted { |
| return false |
| } |
| } |
| |
| // Don't benchmark if the change has negative scores. |
| if codeReviewScore < 0 || verifiedScore < 0 { |
| return false |
| } |
| |
| // Has the latest patchset already been benchmarked? |
| for _, msg := range change.Messages { |
| if msg.RevisionNumber == current.Number && |
| msg.Author.Email == e.cfg.Gerrit.Email { |
| return false |
| } |
| } |
| |
| return true |
| }() |
| if !canBenchmark { |
| continue |
| } |
| |
| priority := 0 |
| if presubmitReady { |
| priority += 10 |
| } |
| priority += codeReviewScore |
| if codeReviewApproved { |
| priority += 2 |
| } |
| if kokoroApproved { |
| priority++ |
| } |
| |
| candidates = append(candidates, candidate{change, priority}) |
| } |
| |
| // Sort the candidates |
| sort.Slice(candidates, func(i, j int) bool { |
| return candidates[i].priority > candidates[j].priority |
| }) |
| |
| if len(candidates) > 0 { |
| log.Printf("%d gerrit changes to benchmark", len(candidates)) |
| return &candidates[0].change, nil |
| } |
| return nil, nil |
| } |
| |
| // benchmarks the gerrit change, posting the findings to the change |
| func (e env) benchmarkGerritChange(change gerrit.ChangeInfo) error { |
| current := change.Revisions[change.CurrentRevision] |
| fmt.Println("benchmarking", change.URL) |
| log.Printf("fetching '%v'...", current.Ref) |
| currentHash, err := e.dawnRepo.Fetch(current.Ref, &git.FetchOptions{ |
| Credentials: e.cfg.Dawn.Credentials, |
| }) |
| if err != nil { |
| return err |
| } |
| parent := current.Commit.Parents[0] |
| parentHash, err := git.ParseHash(parent.Commit) |
| if err != nil { |
| return fmt.Errorf("failed to parse parent hash '%v':\n %v", parent, err) |
| } |
| |
| postMsg := func(notify, msg string) error { |
| _, resp, err := e.gerrit.Changes.SetReview(change.ChangeID, currentHash.String(), &gerrit.ReviewInput{ |
| Message: msg, |
| Tag: "autogenerated:perfmon", |
| Notify: notify, |
| }) |
| |
| if err != nil { |
| body, _ := io.ReadAll(resp.Body) |
| info := &strings.Builder{} |
| fmt.Fprintln(info, "response: ", string(body)) |
| fmt.Fprintln(info, "change-id: ", change.ChangeID) |
| fmt.Fprintln(info, "revision-id: ", currentHash.String()) |
| fmt.Fprintln(info, "notify: ", notify) |
| fmt.Fprintf(info, "msg:\n<<%v>>\n", msg) |
| return fmt.Errorf("failed to post message to gerrit change:\n %v\n%v", err, info.String()) |
| } |
| return nil |
| } |
| |
| newRun, err := e.benchmarkTintChange(currentHash, change.Subject) |
| if err != nil { |
| log.Printf("ERROR: %v", err) |
| buildErr := errFailedToBuild{} |
| if errors.As(err, &buildErr) { |
| return postMsg("OWNER", fmt.Sprintf("patchset %v failed to build", current.Number)) |
| } |
| benchErr := errFailedToBenchmark{} |
| if errors.As(err, &benchErr) { |
| return postMsg("OWNER", fmt.Sprintf("patchset %v failed to benchmark", current.Number)) |
| } |
| return err |
| } |
| if _, err := e.dawnRepo.Fetch(parent.Commit, &git.FetchOptions{ |
| Credentials: e.cfg.Dawn.Credentials, |
| }); err != nil { |
| return err |
| } |
| parentRun, err := e.benchmarkTintChangeIfNotCached(parentHash, |
| fmt.Sprintf("[parent of %v] %v", currentHash.String()[:7], parent.Subject)) |
| if err != nil { |
| return err |
| } |
| |
| const minDiff = time.Microsecond * 50 // Ignore time diffs less than this duration |
| const minRelDiff = 0.01 // Ignore absolute relative diffs between [1, 1+x] |
| diff := bench.Compare(parentRun.Benchmarks, newRun.Benchmarks, minDiff, minRelDiff) |
| diffFmt := bench.DiffFormat{ |
| TestName: true, |
| Delta: true, |
| PercentChangeAB: true, |
| TimeA: true, |
| TimeB: true, |
| } |
| |
| msg := &strings.Builder{} |
| fmt.Fprintln(msg, "Perfmon analysis:") |
| fmt.Fprintln(msg) |
| fmt.Fprintln(msg, "```") |
| fmt.Fprintf(msg, "A: parent change (%v) -> B: patchset %v\n", parent.Commit[:7], current.Number) |
| fmt.Fprintln(msg) |
| lines := strings.Split(diff.Format(diffFmt), "\n") |
| const kMaxLines = 50 |
| if n := len(lines); n > kMaxLines { |
| trimmed := make([]string, 0, kMaxLines+1) |
| trimmed = append(trimmed, lines[:(kMaxLines/2)]...) |
| trimmed = append(trimmed, fmt.Sprintf("... omitting %v rows ...", n-kMaxLines)) |
| trimmed = append(trimmed, lines[n-(kMaxLines/2):]...) |
| lines = trimmed |
| } |
| for _, line := range lines { |
| fmt.Fprintf(msg, " %v\n", line) |
| } |
| fmt.Fprintln(msg, "```") |
| |
| notify := "OWNER" |
| if len(diff) > 0 { |
| notify = "OWNER_REVIEWERS" |
| } |
| return postMsg(notify, msg.String()) |
| } |
| |
| // waitForTempsToSettle waits for the maximum temperature of all sensors to drop |
| // below the threshold value specified by the config. |
| func (e env) waitForTempsToSettle() error { |
| if e.cfg.CPUTempSensorName == "" { |
| time.Sleep(time.Second * 30) |
| return nil |
| } |
| const timeout = 5 * time.Minute |
| start := time.Now() |
| for { |
| temp, err := maxTemp(e.cfg.CPUTempSensorName) |
| if err != nil { |
| return fmt.Errorf("failed to obtain system temeratures: %v", err) |
| } |
| if temp < e.cfg.BenchmarkMaxTemp { |
| log.Printf("temperatures settled. current: %v°C", temp) |
| return nil |
| } |
| if time.Since(start) > timeout { |
| log.Printf("timeout waiting for temperatures to settle. current: %v°C", temp) |
| return nil |
| } |
| log.Printf("waiting for temperatures to settle. current: %v°C, max: %v°C", temp, e.cfg.BenchmarkMaxTemp) |
| time.Sleep(time.Second * 10) |
| } |
| } |
| |
| // createOrOpenGitRepo creates a new local repo by cloning cfg.URL into |
| // filepath, or opens the existing repo at filepath. |
| func createOrOpenGitRepo(g *git.Git, filepath string, cfg GitConfig) (*git.Repository, error) { |
| repo, err := g.Open(filepath) |
| if errors.Is(err, git.ErrRepositoryDoesNotExist) { |
| log.Printf("cloning '%v' branch '%v' to '%v'...", cfg.URL, cfg.Branch, filepath) |
| repo, err = g.Clone(filepath, cfg.URL, &git.CloneOptions{ |
| Branch: cfg.Branch, |
| Credentials: cfg.Credentials, |
| Timeout: time.Minute * 30, |
| }) |
| } |
| if err != nil { |
| return nil, fmt.Errorf("failed to open git repository '%v':\n %w", filepath, err) |
| } |
| return repo, err |
| } |
| |
| // loadConfig loads the perfmon config file. |
| func loadConfig(path string) (Config, error) { |
| f, err := os.Open(path) |
| if err != nil { |
| return Config{}, fmt.Errorf("failed to open config file at '%v':\n %w", path, err) |
| } |
| cfg := Config{} |
| if err := json.NewDecoder(f).Decode(&cfg); err != nil { |
| return Config{}, fmt.Errorf("failed to load config file at '%v':\n %w", path, err) |
| } |
| cfg.setDefaults() |
| return cfg, nil |
| } |
| |
| // makeWorkingDirs creates the dawn repo and results repo directories. |
| func makeWorkingDirs(cfg Config) (dawnDir, resultsDir string, err error) { |
| wd, err := expandHomeDir(cfg.WorkingDir) |
| if err != nil { |
| return "", "", err |
| } |
| if err := os.MkdirAll(wd, 0777); err != nil { |
| return "", "", fmt.Errorf("failed to create working directory '%v':\n %w", wd, err) |
| } |
| dawnDir = filepath.Join(wd, "dawn") |
| if err := os.MkdirAll(dawnDir, 0777); err != nil { |
| return "", "", fmt.Errorf("failed to create working dawn directory '%v':\n %w", dawnDir, err) |
| } |
| resultsDir = filepath.Join(wd, "results") |
| if err := os.MkdirAll(resultsDir, 0777); err != nil { |
| return "", "", fmt.Errorf("failed to create working results directory '%v':\n %w", resultsDir, err) |
| } |
| return dawnDir, resultsDir, nil |
| } |
| |
| // fetchAndCheckoutLatest calls fetch(cfg.Branch) followed by checkoutLatest(). |
| func fetchAndCheckoutLatest(repo *git.Repository, cfg GitConfig) error { |
| hash, err := repo.Fetch(cfg.Branch, &git.FetchOptions{ |
| Credentials: cfg.Credentials, |
| }) |
| if err != nil { |
| return err |
| } |
| if err := repo.Checkout(hash.String(), nil); err != nil { |
| return err |
| } |
| return checkout(hash, repo) |
| } |
| |
| // checkout checks out the change with the given hash. |
| // Note: call fetch() to ensure that this is the latest change on the |
| // branch. |
| func checkout(hash git.Hash, repo *git.Repository) error { |
| if err := repo.Clean(nil); err != nil { |
| return fmt.Errorf("failed to clean repo '%v':\n %w", hash, err) |
| } |
| if err := repo.Checkout(hash.String(), nil); err != nil { |
| return fmt.Errorf("failed to checkout '%v':\n %w", hash, err) |
| } |
| return nil |
| } |
| |
| // expandHomeDir returns path with all occurrences of '~' replaced with the user |
| // home directory. |
| func expandHomeDir(path string) (string, error) { |
| if strings.ContainsRune(path, '~') { |
| home, err := os.UserHomeDir() |
| if err != nil { |
| return "", fmt.Errorf("failed to expand home dir:\n %w", err) |
| } |
| path = strings.ReplaceAll(path, "~", home) |
| } |
| return path, nil |
| } |
| |
| // tools holds the file paths to the executables used by this tool |
| var tools struct { |
| ccache string |
| cmake string |
| gclient string |
| git string |
| ninja string |
| sensors string |
| } |
| |
| // findTools looks for the file paths for executables used by this tool, |
| // returning an error if any could not be found. |
| func findTools() error { |
| for _, tool := range []struct { |
| name string |
| path *string |
| }{ |
| {"ccache", &tools.ccache}, |
| {"cmake", &tools.cmake}, |
| {"gclient", &tools.gclient}, |
| {"git", &tools.git}, |
| {"ninja", &tools.ninja}, |
| {"sensors", &tools.sensors}, |
| } { |
| path, err := exec.LookPath(tool.name) |
| if err != nil { |
| return fmt.Errorf("failed to find path to '%v':\n %w", tool.name, err) |
| } |
| *tool.path = path |
| } |
| return nil |
| } |
| |
| // copyFile copies the file at srcPath to dstPath. |
| func copyFile(dstPath, srcPath string) error { |
| src, err := os.Open(srcPath) |
| if err != nil { |
| return fmt.Errorf("failed to open file '%v':\n %w", srcPath, err) |
| } |
| defer src.Close() |
| dst, err := os.Create(dstPath) |
| if err != nil { |
| return fmt.Errorf("failed to create file '%v':\n %w", dstPath, err) |
| } |
| defer dst.Close() |
| _, err = io.Copy(dst, src) |
| return err |
| } |
| |
| // The regular expression to parse a temperature from 'sensors' |
| var reTemp = regexp.MustCompile("([0-9]+.[0-9])°C") |
| |
| // maxTemp returns the maximum sensor temperature in celsius returned by 'sensors' |
| func maxTemp(sensorName string) (float32, error) { |
| output, err := call(tools.sensors, "", time.Second*2, sensorName) |
| if err != nil { |
| return 0, err |
| } |
| var maxTemp float32 |
| for _, match := range reTemp.FindAllStringSubmatch(output, -1) { |
| var temp float32 |
| if _, err := fmt.Sscanf(match[1], "%f", &temp); err == nil { |
| if temp > maxTemp { |
| maxTemp = temp |
| } |
| } |
| } |
| return maxTemp, nil |
| } |
| |
| // call invokes the executable exe in the current working directory wd, with |
| // the provided arguments. |
| // If the executable does not complete within the timeout duration, then an |
| // error is returned. |
| func call(exe, wd string, timeout time.Duration, args ...string) (string, error) { |
| ctx, cancel := context.WithTimeout(context.Background(), timeout) |
| defer cancel() |
| args = append([]string{"-n", "-20", exe}, args...) |
| cmd := exec.CommandContext(ctx, "nice", args...) |
| cmd.Dir = wd |
| out, err := cmd.CombinedOutput() |
| if err != nil { |
| // Note: If you get a permission error with 'nice', then you either need |
| // to run as sudo (not recommended), or update your ulimits: |
| // Append to /etc/security/limits.conf: |
| // <user> - nice -20 |
| return string(out), fmt.Errorf("'%v %v' failed:\n %w\n%v", exe, args, err, string(out)) |
| } |
| return string(out), nil |
| } |
| |
| // hash returns a hash of the string representation of 'o'. |
| func hash(o interface{}) string { |
| str := fmt.Sprintf("%+v", o) |
| hash := sha256.New() |
| hash.Write([]byte(str)) |
| return hex.EncodeToString(hash.Sum(nil))[:8] |
| } |
| |
| func min(a, b int) int { |
| if a < b { |
| return a |
| } |
| return b |
| } |
| |
| func max(a, b int) int { |
| if a > b { |
| return a |
| } |
| return b |
| } |