From 0699b4f21d601cb5d98ba4396d43dd3c2b3c7fd0 Mon Sep 17 00:00:00 2001 From: Ben Clayton Date: Mon, 15 May 2023 12:49:23 +0000 Subject: [PATCH] [tools] Add 'time-cmd' A command that can time how long a command takes to execute for each file in a filepath glob. Useful for finding the top-N shaders that take the longest to compile in a large corpus directory. Change-Id: I416f300f7344480a939b9304bd3b49c378af9fef Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/132641 Kokoro: Kokoro Commit-Queue: Ben Clayton Reviewed-by: Antonio Maiorano --- tools/src/cmd/time-cmd/main.go | 154 +++++++++++++++++++++++++++++++++ tools/src/glob/glob.go | 47 +++++++++- 2 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 tools/src/cmd/time-cmd/main.go diff --git a/tools/src/cmd/time-cmd/main.go b/tools/src/cmd/time-cmd/main.go new file mode 100644 index 0000000000..fb1d08b355 --- /dev/null +++ b/tools/src/cmd/time-cmd/main.go @@ -0,0 +1,154 @@ +// Copyright 2023 The Tint 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. + +// time-cmd runs a given command for each file found in a glob, returning the +// sorted run times. +package main + +import ( + "flag" + "fmt" + "math/rand" + "os" + "os/exec" + "sort" + "strings" + "text/tabwriter" + "time" + + "dawn.googlesource.com/dawn/tools/src/fileutils" + "dawn.googlesource.com/dawn/tools/src/glob" + "dawn.googlesource.com/dawn/tools/src/progressbar" +) + +func main() { + if err := run(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} +func run() error { + flag.Usage = func() { + out := flag.CommandLine.Output() + fmt.Fprintf(out, "time-cmd runs a given command for each file found in a glob, returning the sorted run times..\n") + fmt.Fprintf(out, "\n") + fmt.Fprintf(out, "Usage:\n") + fmt.Fprintf(out, " time-cmd [flags] \n") + fmt.Fprintf(out, "\n") + fmt.Fprintf(out, "cmd is the path to the command to run\n") + fmt.Fprintf(out, "args are optional command line arguments to 'cmd'\n") + fmt.Fprintf(out, "args should include '%%F' for the globbed file path\n") + fmt.Fprintf(out, "\n") + fmt.Fprintf(out, "flags may be any combination of:\n") + flag.PrintDefaults() + } + var fileGlob string + var top, runs int + flag.StringVar(&fileGlob, "f", "", "the files to glob. Paths are relative to the dawn root directory") + flag.IntVar(&top, "top", 0, "displays the longest N results") + flag.IntVar(&runs, "runs", 1, "takes the average of N runs") + flag.Parse() + + args := flag.Args() + if fileGlob == "" { + flag.Usage() + return fmt.Errorf("Missing --f flag") + } + if len(args) < 1 { + flag.Usage() + return fmt.Errorf("Missing command") + } + + fileGlob = fileutils.ExpandHome(fileGlob) + + exe, err := exec.LookPath(fileutils.ExpandHome(args[0])) + if err != nil { + return fmt.Errorf("could not find executable '%v'", args[0]) + } + + files, err := glob.Glob(fileGlob) + if err != nil { + return err + } + + if len(files) == 0 { + fmt.Println("no files found with glob '" + fileGlob + "'") + return nil + } + + pb := progressbar.New(os.Stdout, nil) + defer pb.Stop() + + type Result struct { + file string + time time.Duration + errs []error + } + status := progressbar.Status{ + Total: len(files) * runs, + Segments: []progressbar.Segment{{Color: progressbar.Green}, {Color: progressbar.Red}}, + } + segSuccess := &status.Segments[0] + segError := &status.Segments[1] + + results := make([]Result, len(files)) + for i, f := range files { + results[i].file = f + } + + for run := 0; run < runs; run++ { + rand.Shuffle(len(results), func(i, j int) { results[i], results[j] = results[j], results[i] }) + for i := range results { + result := &results[i] + exeArgs := make([]string, len(args[1:])) + for i, arg := range args[1:] { + exeArgs[i] = strings.ReplaceAll(arg, "%F", result.file) + } + cmd := exec.Command(exe, exeArgs...) + + start := time.Now() + out, err := cmd.CombinedOutput() + time := time.Since(start) + + result.time += time + if err != nil { + result.errs = append(result.errs, fmt.Errorf("%v", string(out))) + segError.Count++ + } else { + segSuccess.Count++ + } + pb.Update(status) + } + } + + sort.Slice(results, func(i, j int) bool { return results[i].time > results[j].time }) + + if top > 0 { + if top > len(results) { + top = len(results) + } + results = results[:top] + } + + tw := tabwriter.NewWriter(os.Stdout, 1, 0, 1, ' ', 0) + defer tw.Flush() + for i, r := range results { + s := "" + if len(r.errs) > 0 { + s = fmt.Sprint(r.errs) + } + fmt.Fprintf(tw, "%v\t%v\t%v\t%v\n", i, r.time/time.Duration(runs), r.file, s) + } + return nil +} diff --git a/tools/src/glob/glob.go b/tools/src/glob/glob.go index 829f923238..4d61afa1b1 100644 --- a/tools/src/glob/glob.go +++ b/tools/src/glob/glob.go @@ -27,6 +27,43 @@ import ( "dawn.googlesource.com/dawn/tools/src/match" ) +// Glob returns all the strings that match the given filepath glob +func Glob(str string) ([]string, error) { + abs, err := filepath.Abs(str) + if err != nil { + return nil, err + } + root, glob := "", "" + // Look for rightmost directory delimiter that's left of a wildcard. Use + // that to split the 'root' from the match 'glob'. + for i, c := range abs { + switch c { + case '/': + root, glob = abs[:i], abs[i+1:] + case '*', '?': + test, err := match.New(glob) + if err != nil { + return nil, err + } + files, err := Scan(root, Config{Paths: searchRules{ + func(path string, cond bool) bool { return test(path) }, + }}) + if err != nil { + return nil, err + } + for i, f := range files { + files[i] = filepath.Join(root, f) // rel -> abs + } + return files, nil + } + } + // No wildcard found. Does the file exist at 'str'? + if s, err := os.Stat(str); err != nil && !s.IsDir() { + return []string{str}, nil + } + return []string{}, nil +} + // Scan walks all files and subdirectories from root, returning those // that Config.shouldExamine() returns true for. func Scan(root string, cfg Config) ([]string, error) { @@ -155,12 +192,15 @@ func (l *searchRules) UnmarshalJSON(body []byte) error { tests[i] = test } *l = append(*l, func(path string, cond bool) bool { + if cond { + return true + } for _, test := range tests { if test(path) { return true } } - return cond + return false }) case len(rule.Exclude) > 0: tests := make([]match.Test, len(rule.Exclude)) @@ -172,12 +212,15 @@ func (l *searchRules) UnmarshalJSON(body []byte) error { tests[i] = test } *l = append(*l, func(path string, cond bool) bool { + if !cond { + return false + } for _, test := range tests { if test(path) { return false } } - return cond + return true }) } }