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 }