github.com/jgbaldwinbrown/perf@v0.1.1/analysis/app/compare.go (about)

     1  // Copyright 2017 The Go Authors.  All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  package app
     6  
     7  import (
     8  	"bytes"
     9  	"errors"
    10  	"fmt"
    11  	"html/template"
    12  	"net/http"
    13  	"os"
    14  	"path/filepath"
    15  	"sort"
    16  	"strconv"
    17  	"strings"
    18  	"unicode"
    19  
    20  	"golang.org/x/net/context"
    21  	"golang.org/x/perf/benchstat"
    22  	"golang.org/x/perf/storage/benchfmt"
    23  	"golang.org/x/perf/storage/query"
    24  )
    25  
    26  // A resultGroup holds a list of results and tracks the distinct labels found in that list.
    27  type resultGroup struct {
    28  	// The (partial) query string that resulted in this group.
    29  	Q string
    30  	// Raw list of results.
    31  	results []*benchfmt.Result
    32  	// LabelValues is the count of results found with each distinct (key, value) pair found in labels.
    33  	// A value of "" counts results missing that key.
    34  	LabelValues map[string]valueSet
    35  }
    36  
    37  // add adds res to the resultGroup.
    38  func (g *resultGroup) add(res *benchfmt.Result) {
    39  	g.results = append(g.results, res)
    40  	if g.LabelValues == nil {
    41  		g.LabelValues = make(map[string]valueSet)
    42  	}
    43  	for k, v := range res.Labels {
    44  		if g.LabelValues[k] == nil {
    45  			g.LabelValues[k] = make(valueSet)
    46  			if len(g.results) > 1 {
    47  				g.LabelValues[k][""] = len(g.results) - 1
    48  			}
    49  		}
    50  		g.LabelValues[k][v]++
    51  	}
    52  	for k := range g.LabelValues {
    53  		if res.Labels[k] == "" {
    54  			g.LabelValues[k][""]++
    55  		}
    56  	}
    57  }
    58  
    59  // splitOn returns a new set of groups sharing a common value for key.
    60  func (g *resultGroup) splitOn(key string) []*resultGroup {
    61  	groups := make(map[string]*resultGroup)
    62  	var values []string
    63  	for _, res := range g.results {
    64  		value := res.Labels[key]
    65  		if groups[value] == nil {
    66  			groups[value] = &resultGroup{Q: key + ":" + value}
    67  			values = append(values, value)
    68  		}
    69  		groups[value].add(res)
    70  	}
    71  
    72  	sort.Strings(values)
    73  	var out []*resultGroup
    74  	for _, value := range values {
    75  		out = append(out, groups[value])
    76  	}
    77  	return out
    78  }
    79  
    80  // valueSet is a set of values and the number of results with each value.
    81  type valueSet map[string]int
    82  
    83  // valueCount and byCount are used for sorting a valueSet
    84  type valueCount struct {
    85  	Value string
    86  	Count int
    87  }
    88  type byCount []valueCount
    89  
    90  func (s byCount) Len() int      { return len(s) }
    91  func (s byCount) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
    92  
    93  func (s byCount) Less(i, j int) bool {
    94  	if s[i].Count != s[j].Count {
    95  		return s[i].Count > s[j].Count
    96  	}
    97  	return s[i].Value < s[j].Value
    98  }
    99  
   100  // TopN returns a slice containing n valueCount entries, and if any labels were omitted, an extra entry with value "…".
   101  func (vs valueSet) TopN(n int) []valueCount {
   102  	var s []valueCount
   103  	var total int
   104  	for v, count := range vs {
   105  		s = append(s, valueCount{v, count})
   106  		total += count
   107  	}
   108  	sort.Sort(byCount(s))
   109  	out := s
   110  	if len(out) > n {
   111  		out = s[:n]
   112  	}
   113  	if len(out) < len(s) {
   114  		var outTotal int
   115  		for _, vc := range out {
   116  			outTotal += vc.Count
   117  		}
   118  		out = append(out, valueCount{"…", total - outTotal})
   119  	}
   120  	return out
   121  }
   122  
   123  // addToQuery returns a new query string with add applied as a filter.
   124  func addToQuery(query, add string) string {
   125  	if strings.ContainsAny(add, " \t\\\"") {
   126  		add = strings.Replace(add, `\`, `\\`, -1)
   127  		add = strings.Replace(add, `"`, `\"`, -1)
   128  		add = `"` + add + `"`
   129  	}
   130  	if strings.Contains(query, "|") {
   131  		return add + " " + query
   132  	}
   133  	return add + " | " + query
   134  }
   135  
   136  // linkify returns a link related to the label's value. If no such link exists, it returns an empty string.
   137  // For example, "cl: 1234" is linked to golang.org/cl/1234.
   138  // string is used as the return type and not template.URL so that html/template will validate the scheme.
   139  func linkify(labels benchfmt.Labels, label string) string {
   140  	switch label {
   141  	case "cl", "commit":
   142  		return "https://golang.org/cl/" + template.URLQueryEscaper(labels[label])
   143  	case "ps":
   144  		// TODO(quentin): Figure out how to link to a particular patch set on Gerrit.
   145  		return ""
   146  	case "repo":
   147  		return labels["repo"]
   148  	case "try":
   149  		// TODO(quentin): Return link to farmer once farmer has permalinks.
   150  		return ""
   151  	}
   152  	return ""
   153  }
   154  
   155  // compare handles queries that require comparison of the groups in the query.
   156  func (a *App) compare(w http.ResponseWriter, r *http.Request) {
   157  	ctx := requestContext(r)
   158  
   159  	if err := r.ParseForm(); err != nil {
   160  		http.Error(w, err.Error(), 500)
   161  		return
   162  	}
   163  
   164  	q := r.Form.Get("q")
   165  
   166  	tmpl, err := os.ReadFile(filepath.Join(a.BaseDir, "template/compare.html"))
   167  	if err != nil {
   168  		http.Error(w, err.Error(), 500)
   169  		return
   170  	}
   171  
   172  	t, err := template.New("main").Funcs(template.FuncMap{
   173  		"addToQuery": addToQuery,
   174  		"linkify":    linkify,
   175  	}).Parse(string(tmpl))
   176  	if err != nil {
   177  		http.Error(w, err.Error(), 500)
   178  		return
   179  	}
   180  
   181  	data := a.compareQuery(ctx, q)
   182  
   183  	w.Header().Set("Content-Type", "text/html; charset=utf-8")
   184  	if err := t.Execute(w, data); err != nil {
   185  		http.Error(w, err.Error(), 500)
   186  		return
   187  	}
   188  }
   189  
   190  type compareData struct {
   191  	Q            string
   192  	Error        string
   193  	Benchstat    template.HTML
   194  	Groups       []*resultGroup
   195  	Labels       map[string]bool
   196  	CommonLabels benchfmt.Labels
   197  }
   198  
   199  // queryKeys returns the keys that are exact-matched by q.
   200  func queryKeys(q string) map[string]bool {
   201  	out := make(map[string]bool)
   202  	for _, part := range query.SplitWords(q) {
   203  		// TODO(quentin): This func is shared with db.go; refactor?
   204  		i := strings.IndexFunc(part, func(r rune) bool {
   205  			return r == ':' || r == '>' || r == '<' || unicode.IsSpace(r) || unicode.IsUpper(r)
   206  		})
   207  		if i >= 0 && part[i] == ':' {
   208  			out[part[:i]] = true
   209  		}
   210  	}
   211  	return out
   212  }
   213  
   214  // elideKeyValues returns content, a benchmark format line, with the
   215  // values of any keys in keys elided.
   216  func elideKeyValues(content string, keys map[string]bool) string {
   217  	var end string
   218  	if i := strings.IndexFunc(content, unicode.IsSpace); i >= 0 {
   219  		content, end = content[:i], content[i:]
   220  	}
   221  	// Check for gomaxprocs value
   222  	if i := strings.LastIndex(content, "-"); i >= 0 {
   223  		_, err := strconv.Atoi(content[i+1:])
   224  		if err == nil {
   225  			if keys["gomaxprocs"] {
   226  				content, end = content[:i], "-*"+end
   227  			} else {
   228  				content, end = content[:i], content[i:]+end
   229  			}
   230  		}
   231  	}
   232  	parts := strings.Split(content, "/")
   233  	for i, part := range parts {
   234  		if equals := strings.Index(part, "="); equals >= 0 {
   235  			if keys[part[:equals]] {
   236  				parts[i] = part[:equals] + "=*"
   237  			}
   238  		} else if i == 0 {
   239  			if keys["name"] {
   240  				parts[i] = "Benchmark*"
   241  			}
   242  		} else if keys[fmt.Sprintf("sub%d", i)] {
   243  			parts[i] = "*"
   244  		}
   245  	}
   246  	return strings.Join(parts, "/") + end
   247  }
   248  
   249  // fetchCompareResults fetches the matching results for a given query string.
   250  // The results will be grouped into one or more groups based on either the query string or heuristics.
   251  func (a *App) fetchCompareResults(ctx context.Context, q string) ([]*resultGroup, error) {
   252  	// Parse query
   253  	prefix, queries := parseQueryString(q)
   254  
   255  	// Send requests
   256  	// TODO(quentin): Issue requests in parallel?
   257  	var groups []*resultGroup
   258  	var found int
   259  	for _, qPart := range queries {
   260  		keys := queryKeys(qPart)
   261  		group := &resultGroup{Q: qPart}
   262  		if prefix != "" {
   263  			qPart = prefix + " " + qPart
   264  		}
   265  		res := a.StorageClient.Query(ctx, qPart)
   266  		for res.Next() {
   267  			result := res.Result()
   268  			result.Content = elideKeyValues(result.Content, keys)
   269  			group.add(result)
   270  			found++
   271  		}
   272  		err := res.Err()
   273  		res.Close()
   274  		if err != nil {
   275  			// TODO: If the query is invalid, surface that to the user.
   276  			return nil, err
   277  		}
   278  		groups = append(groups, group)
   279  	}
   280  
   281  	if found == 0 {
   282  		return nil, errors.New("no results matched the query string")
   283  	}
   284  
   285  	// Attempt to automatically split results.
   286  	if len(groups) == 1 {
   287  		group := groups[0]
   288  		// Matching a single CL -> split by filename
   289  		switch {
   290  		case len(group.LabelValues["cl"]) == 1 && len(group.LabelValues["ps"]) == 1 && len(group.LabelValues["upload-file"]) > 1:
   291  			groups = group.splitOn("upload-file")
   292  		// Matching a single upload with multiple files -> split by file
   293  		case len(group.LabelValues["upload"]) == 1 && len(group.LabelValues["upload-part"]) > 1:
   294  			groups = group.splitOn("upload-part")
   295  		}
   296  	}
   297  
   298  	return groups, nil
   299  }
   300  
   301  func (a *App) compareQuery(ctx context.Context, q string) *compareData {
   302  	if len(q) == 0 {
   303  		return &compareData{}
   304  	}
   305  
   306  	groups, err := a.fetchCompareResults(ctx, q)
   307  	if err != nil {
   308  		return &compareData{
   309  			Q:     q,
   310  			Error: err.Error(),
   311  		}
   312  	}
   313  
   314  	var buf bytes.Buffer
   315  	// Compute benchstat
   316  	c := &benchstat.Collection{
   317  		AddGeoMean: true,
   318  		SplitBy:    nil,
   319  	}
   320  	for _, label := range []string{"buildlet", "pkg", "goos", "goarch"} {
   321  		for _, g := range groups {
   322  			if len(g.LabelValues[label]) > 1 {
   323  				c.SplitBy = append(c.SplitBy, label)
   324  				break
   325  			}
   326  		}
   327  	}
   328  	for _, g := range groups {
   329  		c.AddResults(g.Q, g.results)
   330  	}
   331  	benchstat.FormatHTML(&buf, c.Tables())
   332  
   333  	// Prepare struct for template.
   334  	labels := make(map[string]bool)
   335  	// commonLabels are the key: value of every label that has an
   336  	// identical value on every result.
   337  	commonLabels := make(benchfmt.Labels)
   338  	// Scan the first group for common labels.
   339  	for k, vs := range groups[0].LabelValues {
   340  		if len(vs) == 1 {
   341  			for v := range vs {
   342  				commonLabels[k] = v
   343  			}
   344  		}
   345  	}
   346  	// Remove any labels not common in later groups.
   347  	for _, g := range groups[1:] {
   348  		for k, v := range commonLabels {
   349  			if len(g.LabelValues[k]) != 1 || g.LabelValues[k][v] == 0 {
   350  				delete(commonLabels, k)
   351  			}
   352  		}
   353  	}
   354  	// List all labels present and not in commonLabels.
   355  	for _, g := range groups {
   356  		for k := range g.LabelValues {
   357  			if commonLabels[k] != "" {
   358  				continue
   359  			}
   360  			labels[k] = true
   361  		}
   362  	}
   363  	data := &compareData{
   364  		Q:            q,
   365  		Benchstat:    template.HTML(buf.String()),
   366  		Groups:       groups,
   367  		Labels:       labels,
   368  		CommonLabels: commonLabels,
   369  	}
   370  	return data
   371  }
   372  
   373  // textCompare is called if benchsave is requesting a text-only analysis.
   374  func (a *App) textCompare(w http.ResponseWriter, r *http.Request) {
   375  	ctx := requestContext(r)
   376  
   377  	if err := r.ParseForm(); err != nil {
   378  		http.Error(w, err.Error(), 500)
   379  		return
   380  	}
   381  
   382  	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
   383  
   384  	q := r.Form.Get("q")
   385  
   386  	groups, err := a.fetchCompareResults(ctx, q)
   387  	if err != nil {
   388  		// TODO(quentin): Should we serve this with a 500 or 404? This means the query was invalid or had no results.
   389  		fmt.Fprintf(w, "unable to analyze results: %v", err)
   390  	}
   391  
   392  	// Compute benchstat
   393  	c := new(benchstat.Collection)
   394  	for _, g := range groups {
   395  		c.AddResults(g.Q, g.results)
   396  	}
   397  	benchstat.FormatText(w, c.Tables())
   398  }