From 40bed821f80333a556f4b3a9f93394502dd49a02 Mon Sep 17 00:00:00 2001 From: Ben Clayton Date: Fri, 29 Apr 2022 19:09:17 +0000 Subject: [PATCH] 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 Kokoro: Kokoro --- go.mod | 2 + go.sum | 3 + tools/src/cmd/cts/common/build.go | 157 ++++++++++++ tools/src/cmd/cts/common/constants.go | 37 +++ tools/src/cmd/cts/common/results.go | 349 ++++++++++++++++++++++++++ tools/src/cmd/cts/main.go | 1 + tools/src/cmd/cts/time/time.go | 148 +++++++++++ 7 files changed, 697 insertions(+) create mode 100644 tools/src/cmd/cts/common/build.go create mode 100644 tools/src/cmd/cts/common/constants.go create mode 100644 tools/src/cmd/cts/common/results.go create mode 100644 tools/src/cmd/cts/time/time.go diff --git a/go.mod b/go.mod index 140ce89bc0..b6d7bb1670 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index b5d4243364..bdd66d6da7 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/tools/src/cmd/cts/common/build.go b/tools/src/cmd/cts/common/build.go new file mode 100644 index 0000000000..ca4985c11f --- /dev/null +++ b/tools/src/cmd/cts/common/build.go @@ -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 +} diff --git a/tools/src/cmd/cts/common/constants.go b/tools/src/cmd/cts/common/constants.go new file mode 100644 index 0000000000..a210f0d77b --- /dev/null +++ b/tools/src/cmd/cts/common/constants.go @@ -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 +} diff --git a/tools/src/cmd/cts/common/results.go b/tools/src/cmd/cts/common/results.go new file mode 100644 index 0000000000..f32111b525 --- /dev/null +++ b/tools/src/cmd/cts/common/results.go @@ -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) + } + } + } +} diff --git a/tools/src/cmd/cts/main.go b/tools/src/cmd/cts/main.go index 542ae40f34..5295e7f177 100644 --- a/tools/src/cmd/cts/main.go +++ b/tools/src/cmd/cts/main.go @@ -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() { diff --git a/tools/src/cmd/cts/time/time.go b/tools/src/cmd/cts/time/time.go new file mode 100644 index 0000000000..7bcb682a06 --- /dev/null +++ b/tools/src/cmd/cts/time/time.go @@ -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 +}