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 }