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