tools: Add the 'cts time' sub-command

Add the common utilities for starting and fetching build results.

'cts time' provides views on the longest running tests, along with
a histogram view of all test times.

Bug: dawn:1342
Change-Id: Ia3707f7f062ea26a2406e3163a26e1cc7e30e3b2
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/88319
Reviewed-by: Corentin Wallez <cwallez@chromium.org>
Kokoro: Kokoro <noreply+kokoro@google.com>
This commit is contained in:
Ben Clayton 2022-04-29 19:09:17 +00:00
parent 251e464af1
commit 40bed821f8
7 changed files with 697 additions and 0 deletions

2
go.mod
View File

@ -27,8 +27,10 @@ require (
github.com/klauspost/compress v1.13.5 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/maruel/subcommands v1.1.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/rogpeppe/go-internal v1.8.0 // indirect
github.com/texttheater/golang-levenshtein v1.0.1 // indirect
github.com/tklauser/go-sysconf v0.3.10 // indirect
github.com/tklauser/numcpus v0.4.0 // indirect
github.com/yusufpapurcu/wmi v1.2.2 // indirect

3
go.sum
View File

@ -337,7 +337,9 @@ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6Fm
github.com/luci/gtreap v0.0.0-20161228054646-35df89791e8f/go.mod h1:OjKOY0UvVOOH5nWXSIWTbQWESn8dDiGlaEZx6IAsWhU=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/maruel/subcommands v1.1.0 h1:5k7Y1KXDrI4U2Q7J6R7rbnpoNAiklUDTdaK2fFT998g=
github.com/maruel/subcommands v1.1.0/go.mod h1:b25AG9Eho2Rs1NUPAPAYBFy1B5y63QMxw/2WmLGO8m8=
github.com/maruel/ut v1.0.2 h1:mQTlQk3jubTbdTcza+hwoZQWhzcvE4L6K6RTtAFlA1k=
github.com/maruel/ut v1.0.2/go.mod h1:RV8PwPD9dd2KFlnlCc/DB2JVvkXmyaalfc5xvmSrRSs=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
@ -455,6 +457,7 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/texttheater/golang-levenshtein v1.0.1 h1:+cRNoVrfiwufQPhoMzB6N0Yf/Mqajr6t1lOv8GyGE2U=
github.com/texttheater/golang-levenshtein v1.0.1/go.mod h1:PYAKrbF5sAiq9wd+H82hs7gNaen0CplQ9uvm6+enD/8=
github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc=
github.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE=

View File

@ -0,0 +1,157 @@
// Copyright 2022 The Dawn 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 common
import (
"context"
"fmt"
"log"
"sort"
"time"
"dawn.googlesource.com/dawn/tools/src/buildbucket"
"dawn.googlesource.com/dawn/tools/src/gerrit"
)
// BuildsByName is a map of builder name to build result
type BuildsByName map[string]buildbucket.Build
func (b BuildsByName) ids() []buildbucket.BuildID {
ids := make([]buildbucket.BuildID, 0, len(b))
for _, build := range b {
ids = append(ids, build.ID)
}
return ids
}
// GetBuilds returns the builds, as declared in the config file, for the given
// patchset
func GetBuilds(
ctx context.Context,
cfg Config,
ps gerrit.Patchset,
bb *buildbucket.Buildbucket) (BuildsByName, error) {
builds := BuildsByName{}
err := bb.SearchBuilds(ctx, ps, func(build buildbucket.Build) error {
for name, builder := range cfg.Builders {
if build.Builder == builder {
builds[name] = build
break
}
}
return nil
})
if err != nil {
return nil, err
}
return builds, err
}
// WaitForBuildsToComplete waits until all the provided builds have finished.
func WaitForBuildsToComplete(
ctx context.Context,
cfg Config,
ps gerrit.Patchset,
bb *buildbucket.Buildbucket,
builds BuildsByName) error {
buildsStillRunning := func() []string {
out := []string{}
for name, build := range builds {
if build.Status.Running() {
out = append(out, name)
}
}
sort.Strings(out)
return out
}
for {
// Refresh build status
for name, build := range builds {
build, err := bb.QueryBuild(ctx, build.ID)
if err != nil {
return fmt.Errorf("failed to query build for '%v': %w", name, err)
}
builds[name] = build
}
running := buildsStillRunning()
if len(running) == 0 {
break
}
log.Println("waiting for builds to complete: ", running)
time.Sleep(time.Minute * 2)
}
for name, build := range builds {
if build.Status == buildbucket.StatusInfraFailure ||
build.Status == buildbucket.StatusCanceled {
return fmt.Errorf("%v builder failed with %v", name, build.Status)
}
}
return nil
}
// GetOrStartBuildsAndWait starts the builds as declared in the config file,
// for the given patchset, if they haven't already been started or if retest is
// true. GetOrStartBuildsAndWait then waits for the builds to complete, and then
// returns the results.
func GetOrStartBuildsAndWait(
ctx context.Context,
cfg Config,
ps gerrit.Patchset,
bb *buildbucket.Buildbucket,
retest bool) (BuildsByName, error) {
builds := BuildsByName{}
if !retest {
// Find any existing builds for the patchset
err := bb.SearchBuilds(ctx, ps, func(build buildbucket.Build) error {
for name, builder := range cfg.Builders {
if build.Builder == builder {
builds[name] = build
break
}
}
return nil
})
if err != nil {
return nil, err
}
}
// Kick any missing builds
for name, builder := range cfg.Builders {
if _, existing := builds[name]; !existing {
build, err := bb.StartBuild(ctx, ps, builder, retest)
if err != nil {
return nil, err
}
log.Printf("started build: %+v", build)
builds[name] = build
}
}
if err := WaitForBuildsToComplete(ctx, cfg, ps, bb, builds); err != nil {
return nil, err
}
return builds, nil
}

View File

@ -0,0 +1,37 @@
// Copyright 2022 The Dawn 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 common
import (
"dawn.googlesource.com/dawn/tools/src/utils"
"go.chromium.org/luci/auth"
"go.chromium.org/luci/hardcoded/chromeinfra"
)
const (
// RollSubjectPrefix is the subject prefix for CTS roll changes
RollSubjectPrefix = "Roll third_party/webgpu-cts/ "
// DefaultCacheDir is the default directory for the results cache
DefaultCacheDir = "~/.cache/webgpu-cts-results"
)
// DefaultAuthOptions returns the default authentication options for use by
// command line arguments.
func DefaultAuthOptions() auth.Options {
def := chromeinfra.DefaultAuthOptions()
def.SecretsDir = utils.ExpandHome("~/.config/dawn-cts")
return def
}

View File

@ -0,0 +1,349 @@
// Copyright 2022 The Dawn 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 common
import (
"context"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"dawn.googlesource.com/dawn/tools/src/buildbucket"
"dawn.googlesource.com/dawn/tools/src/cts/query"
"dawn.googlesource.com/dawn/tools/src/cts/result"
"dawn.googlesource.com/dawn/tools/src/gerrit"
"dawn.googlesource.com/dawn/tools/src/resultsdb"
"dawn.googlesource.com/dawn/tools/src/subcmd"
"dawn.googlesource.com/dawn/tools/src/utils"
"go.chromium.org/luci/auth"
rdbpb "go.chromium.org/luci/resultdb/proto/v1"
)
// ResultSource describes the source of CTS test results.
// ResultSource is commonly used by command line flags for specifying from
// where the results should be loaded / fetched.
// If neither File or Patchset are specified, then results will be fetched from
// the last successful CTS roll.
type ResultSource struct {
// The directory used to cache results fetched from ResultDB
CacheDir string
// If specified, results will be loaded from this file path
// Must not be specified if Patchset is also specified.
File string
// If specified, results will be fetched from this gerrit patchset
// Must not be specified if File is also specified.
Patchset gerrit.Patchset
}
// RegisterFlags registers the ResultSource fields as commandline flags for use
// by command line tools.
func (r *ResultSource) RegisterFlags(cfg Config) {
flag.StringVar(&r.CacheDir, "cache", DefaultCacheDir, "path to the results cache")
flag.StringVar(&r.File, "results", "", "local results.txt file (mutually exclusive with --cl)")
r.Patchset.RegisterFlags(cfg.Gerrit.Host, cfg.Gerrit.Project)
}
// GetResults loads or fetches the results, based on the values of r.
func (r *ResultSource) GetResults(ctx context.Context, cfg Config, auth auth.Options) (result.List, error) {
// Check that File and Patchset weren't both specified
ps := r.Patchset
if r.File != "" && ps.Change != 0 {
fmt.Fprintln(flag.CommandLine.Output(), "only one of --results and --cl can be specified")
return nil, subcmd.ErrInvalidCLA
}
// If a file was specified, then load that.
if r.File != "" {
return result.Load(r.File)
}
// Initialize the buildbucket and resultdb clients
bb, err := buildbucket.New(ctx, auth)
if err != nil {
return nil, err
}
rdb, err := resultsdb.New(ctx, auth)
if err != nil {
return nil, err
}
// If no change was specified, then pull the results from the most recent
// CTS roll.
if ps.Change == 0 {
fmt.Println("no change specified, scanning gerrit for last CTS roll...")
gerrit, err := gerrit.New(cfg.Gerrit.Host, gerrit.Credentials{})
if err != nil {
return nil, err
}
latest, err := LatestCTSRoll(gerrit)
if err != nil {
return nil, err
}
results, ps, err := MostRecentResultsForChange(ctx, cfg, r.CacheDir, gerrit, bb, rdb, latest.Number)
if err != nil {
return nil, err
}
fmt.Printf("using results from cl %v ps %v...\n", ps.Change, ps.Patchset)
return results, nil
}
// If a change, but no patchset was specified, then query the most recent
// patchset.
if ps.Patchset == 0 {
gerrit, err := gerrit.New(cfg.Gerrit.Host, gerrit.Credentials{})
if err != nil {
return nil, err
}
ps, err := gerrit.LatestPatchest(strconv.Itoa(ps.Change))
if err != nil {
err := fmt.Errorf("failed to find latest patchset of change %v: %w",
ps.Change, err)
return nil, err
}
}
// Obtain the patchset's results, kicking a build if there are no results
// already available.
log.Printf("fetching results from cl %v ps %v...", ps.Change, ps.Patchset)
builds, err := GetOrStartBuildsAndWait(ctx, cfg, ps, bb, false)
if err != nil {
return nil, err
}
results, err := CacheResults(ctx, cfg, ps, r.CacheDir, rdb, builds)
if err != nil {
return nil, err
}
return results, nil
}
// CacheResults looks in the cache at 'cacheDir' for the results for the given
// patchset. If the cache contains the results, then these are loaded and
// returned. If the cache does not contain the results, then they are fetched
// using GetResults(), saved to the cache directory and are returned.
func CacheResults(
ctx context.Context,
cfg Config,
ps gerrit.Patchset,
cacheDir string,
rdb *resultsdb.ResultsDB,
builds BuildsByName) (result.List, error) {
var cachePath string
if cacheDir != "" {
dir := utils.ExpandHome(cacheDir)
path := filepath.Join(dir, strconv.Itoa(ps.Change), fmt.Sprintf("ps-%v.txt", ps.Patchset))
if _, err := os.Stat(path); err == nil {
return result.Load(path)
}
cachePath = path
}
results, err := GetResults(ctx, cfg, rdb, builds)
if err != nil {
return nil, err
}
if err := result.Save(cachePath, results); err != nil {
log.Println("failed to save results to cache: %w", err)
}
return results, nil
}
// GetResults fetches the build results from ResultDB.
// GetResults does not trigger new builds.
func GetResults(
ctx context.Context,
cfg Config,
rdb *resultsdb.ResultsDB,
builds BuildsByName) (result.List, error) {
fmt.Printf("fetching results from resultdb...")
lastPrintedDot := time.Now()
toStatus := func(s rdbpb.TestStatus) result.Status {
switch s {
default:
return result.Unknown
case rdbpb.TestStatus_PASS:
return result.Pass
case rdbpb.TestStatus_FAIL:
return result.Failure
case rdbpb.TestStatus_CRASH:
return result.Crash
case rdbpb.TestStatus_ABORT:
return result.Abort
case rdbpb.TestStatus_SKIP:
return result.Skip
}
}
results := result.List{}
err := rdb.QueryTestResults(ctx, builds.ids(), cfg.Test.Prefix+".*", func(rpb *rdbpb.TestResult) error {
if time.Since(lastPrintedDot) > 5*time.Second {
lastPrintedDot = time.Now()
fmt.Printf(".")
}
if !strings.HasPrefix(rpb.GetTestId(), cfg.Test.Prefix) {
return nil
}
testName := rpb.GetTestId()[len(cfg.Test.Prefix):]
status := toStatus(rpb.Status)
tags := result.NewTags()
for _, sp := range rpb.Tags {
if sp.Key == "typ_tag" {
tags.Add(sp.Value)
}
}
if fr := rpb.GetFailureReason(); fr != nil {
if strings.Contains(fr.GetPrimaryErrorMessage(), "asyncio.exceptions.TimeoutError") {
status = result.Slow
}
}
duration := rpb.GetDuration().AsDuration()
if status == result.Pass && duration > cfg.Test.SlowThreshold {
status = result.Slow
}
results = append(results, result.Result{
Query: query.Parse(testName),
Status: status,
Tags: tags,
Duration: duration,
})
return nil
})
fmt.Println(" done")
if err != nil {
return nil, err
}
// Expand any aliased tags
ExpandAliasedTags(cfg, results)
// Remove any duplicates from the results.
results = results.ReplaceDuplicates(result.Deduplicate)
results.Sort()
return results, err
}
// LatestCTSRoll returns for the latest merged CTS roll that landed in the past
// month. If no roll can be found, then an error is returned.
func LatestCTSRoll(g *gerrit.Gerrit) (gerrit.ChangeInfo, error) {
changes, _, err := g.QueryChanges(
`status:merged`,
`-age:1month`,
fmt.Sprintf(`message:"%v"`, RollSubjectPrefix))
if err != nil {
return gerrit.ChangeInfo{}, err
}
if len(changes) == 0 {
return gerrit.ChangeInfo{}, fmt.Errorf("no change found")
}
sort.Slice(changes, func(i, j int) bool {
return changes[i].Submitted.Time.After(changes[j].Submitted.Time)
})
return changes[0], nil
}
// LatestPatchset returns the most recent patchset for the given change.
func LatestPatchset(g *gerrit.Gerrit, change int) (gerrit.Patchset, error) {
ps, err := g.LatestPatchest(strconv.Itoa(change))
if err != nil {
err := fmt.Errorf("failed to find latest patchset of change %v: %w",
ps.Change, err)
return gerrit.Patchset{}, err
}
return ps, nil
}
// MostRecentResultsForChange returns the results from the most recent patchset
// that has build results. If no results can be found for the entire change,
// then an error is returned.
func MostRecentResultsForChange(
ctx context.Context,
cfg Config,
cacheDir string,
g *gerrit.Gerrit,
bb *buildbucket.Buildbucket,
rdb *resultsdb.ResultsDB,
change int) (result.List, gerrit.Patchset, error) {
ps, err := LatestPatchset(g, change)
if err != nil {
return nil, gerrit.Patchset{}, nil
}
for ps.Patchset > 0 {
builds, err := GetBuilds(ctx, cfg, ps, bb)
if err != nil {
return nil, gerrit.Patchset{}, err
}
if len(builds) > 0 {
if err := WaitForBuildsToComplete(ctx, cfg, ps, bb, builds); err != nil {
return nil, gerrit.Patchset{}, err
}
results, err := CacheResults(ctx, cfg, ps, cacheDir, rdb, builds)
if err != nil {
return nil, gerrit.Patchset{}, err
}
if len(results) > 0 {
return results, ps, nil
}
}
ps.Patchset--
}
return nil, gerrit.Patchset{}, fmt.Errorf("no builds found for change %v", change)
}
// ExpandAliasedTags modifies each result so that tags which are found in
// cfg.TagAliases are expanded to include all the tag aliases.
// This is bodge for crbug.com/dawn/1387.
func ExpandAliasedTags(cfg Config, results result.List) {
// Build the result sets
sets := make([]result.Tags, len(cfg.TagAliases))
for i, l := range cfg.TagAliases {
sets[i] = result.NewTags(l...)
}
// Expand the result tags for the aliased tag sets
for _, r := range results {
for _, set := range sets {
if r.Tags.ContainsAny(set) {
r.Tags.AddAll(set)
}
}
}
}

View File

@ -29,6 +29,7 @@ import (
// Register sub-commands
_ "dawn.googlesource.com/dawn/tools/src/cmd/cts/format"
_ "dawn.googlesource.com/dawn/tools/src/cmd/cts/time"
)
func main() {

View File

@ -0,0 +1,148 @@
// Copyright 2022 The Dawn 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 time
import (
"context"
"flag"
"fmt"
"math"
"sort"
"time"
"dawn.googlesource.com/dawn/tools/src/cmd/cts/common"
"dawn.googlesource.com/dawn/tools/src/cts/result"
"dawn.googlesource.com/dawn/tools/src/subcmd"
"go.chromium.org/luci/auth/client/authcli"
)
func init() {
common.Register(&cmd{})
}
type cmd struct {
flags struct {
source common.ResultSource
auth authcli.Flags
tags string
topN int
histogram bool
}
}
func (cmd) Name() string {
return "time"
}
func (cmd) Desc() string {
return "displays timing information for tests"
}
func (c *cmd) RegisterFlags(ctx context.Context, cfg common.Config) ([]string, error) {
c.flags.source.RegisterFlags(cfg)
c.flags.auth.Register(flag.CommandLine, common.DefaultAuthOptions())
flag.IntVar(&c.flags.topN, "top", 0, "print the top N slowest tests")
flag.BoolVar(&c.flags.histogram, "histogram", false, "print a histogram of test timings")
flag.StringVar(&c.flags.tags, "tags", "", "comma-separated list of tags to filter results")
return nil, nil
}
func (c *cmd) Run(ctx context.Context, cfg common.Config) error {
// Validate command line arguments
auth, err := c.flags.auth.Options()
if err != nil {
return fmt.Errorf("failed to obtain authentication options: %w", err)
}
// Obtain the results
results, err := c.flags.source.GetResults(ctx, cfg, auth)
if err != nil {
return err
}
if len(results) == 0 {
return fmt.Errorf("no results found")
}
// If tags were provided, filter the results to those that contain these tags
if c.flags.tags != "" {
results = results.FilterByTags(result.StringToTags(c.flags.tags))
if len(results) == 0 {
return fmt.Errorf("no results after filtering by tags")
}
}
// Sort the results with longest duration first
sort.Slice(results, func(i, j int) bool {
return results[i].Duration > results[j].Duration
})
didSomething := false
// Did the user request --top N ?
if c.flags.topN > 0 {
didSomething = true
topN := results
if c.flags.topN < len(results) {
topN = topN[:c.flags.topN]
}
for i, r := range topN {
fmt.Printf("%3.1d: %v\n", i, r)
}
}
// Did the user request --histogram ?
if c.flags.histogram {
maxTime := results[0].Duration
const (
numBins = 25
pow = 2.0
)
binToDuration := func(i int) time.Duration {
frac := math.Pow(float64(i)/float64(numBins), pow)
return time.Duration(float64(maxTime) * frac)
}
durationToBin := func(d time.Duration) int {
frac := math.Pow(float64(d)/float64(maxTime), 1.0/pow)
idx := int(frac * numBins)
if idx >= numBins-1 {
return numBins - 1
}
return idx
}
didSomething = true
bins := make([]int, numBins)
for _, r := range results {
idx := durationToBin(r.Duration)
bins[idx] = bins[idx] + 1
}
for i, bin := range bins {
fmt.Printf("[%.8v, %.8v]: %v\n", binToDuration(i), binToDuration(i+1), bin)
}
}
// If the user didn't request anything, show a helpful message
if !didSomething {
fmt.Fprintln(flag.CommandLine.Output(), "no action flags specified for", c.Name())
fmt.Fprintln(flag.CommandLine.Output())
flag.Usage()
return subcmd.ErrInvalidCLA
}
return nil
}