blob: 778286741bf40396993612169bf82a15fd810b01 [file] [log] [blame]
// Copyright 2017 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package app
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"unicode"
"github.com/google/safehtml"
"github.com/google/safehtml/template"
"golang.org/x/build/perfdata/query"
"golang.org/x/perf/benchstat"
"golang.org/x/perf/storage/benchfmt"
)
// A resultGroup holds a list of results and tracks the distinct labels found in that list.
type resultGroup struct {
// The (partial) query string that resulted in this group.
Q string
// Raw list of results.
results []*benchfmt.Result
// LabelValues is the count of results found with each distinct (key, value) pair found in labels.
// A value of "" counts results missing that key.
LabelValues map[string]valueSet
}
// add adds res to the resultGroup.
func (g *resultGroup) add(res *benchfmt.Result) {
g.results = append(g.results, res)
if g.LabelValues == nil {
g.LabelValues = make(map[string]valueSet)
}
for k, v := range res.Labels {
if g.LabelValues[k] == nil {
g.LabelValues[k] = make(valueSet)
if len(g.results) > 1 {
g.LabelValues[k][""] = len(g.results) - 1
}
}
g.LabelValues[k][v]++
}
for k := range g.LabelValues {
if res.Labels[k] == "" {
g.LabelValues[k][""]++
}
}
}
// splitOn returns a new set of groups sharing a common value for key.
func (g *resultGroup) splitOn(key string) []*resultGroup {
groups := make(map[string]*resultGroup)
var values []string
for _, res := range g.results {
value := res.Labels[key]
if groups[value] == nil {
groups[value] = &resultGroup{Q: key + ":" + value}
values = append(values, value)
}
groups[value].add(res)
}
sort.Strings(values)
var out []*resultGroup
for _, value := range values {
out = append(out, groups[value])
}
return out
}
// valueSet is a set of values and the number of results with each value.
type valueSet map[string]int
// valueCount and byCount are used for sorting a valueSet
type valueCount struct {
Value string
Count int
}
type byCount []valueCount
func (s byCount) Len() int { return len(s) }
func (s byCount) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
func (s byCount) Less(i, j int) bool {
if s[i].Count != s[j].Count {
return s[i].Count > s[j].Count
}
return s[i].Value < s[j].Value
}
// TopN returns a slice containing n valueCount entries, and if any labels were omitted, an extra entry with value "…".
func (vs valueSet) TopN(n int) []valueCount {
var s []valueCount
var total int
for v, count := range vs {
s = append(s, valueCount{v, count})
total += count
}
sort.Sort(byCount(s))
out := s
if len(out) > n {
out = s[:n]
}
if len(out) < len(s) {
var outTotal int
for _, vc := range out {
outTotal += vc.Count
}
out = append(out, valueCount{"…", total - outTotal})
}
return out
}
// addToQuery returns a new query string with add applied as a filter.
func addToQuery(query, add string) string {
if strings.ContainsAny(add, " \t\\\"") {
add = strings.Replace(add, `\`, `\\`, -1)
add = strings.Replace(add, `"`, `\"`, -1)
add = `"` + add + `"`
}
if strings.Contains(query, "|") {
return add + " " + query
}
return add + " | " + query
}
// linkify returns a link related to the label's value. If no such link exists, it returns an empty string.
// For example, "cl: 1234" is linked to golang.org/cl/1234.
// string is used as the return type and not template.URL so that html/template will validate the scheme.
func linkify(labels benchfmt.Labels, label string) string {
switch label {
case "cl", "commit":
return "https://golang.org/cl/" + url.QueryEscape(labels[label])
case "ps":
// TODO(quentin): Figure out how to link to a particular patch set on Gerrit.
return ""
case "repo":
return labels["repo"]
case "try":
// TODO(quentin): Return link to farmer once farmer has permalinks.
return ""
}
return ""
}
// compare handles queries that require comparison of the groups in the query.
func (a *App) compare(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), 500)
return
}
q := r.Form.Get("q")
t, err := template.New("compare.html").Funcs(template.FuncMap{
"addToQuery": addToQuery,
"linkify": linkify,
}).ParseFS(tmplFS, "template/compare.html")
if err != nil {
http.Error(w, err.Error(), 500)
return
}
data := a.compareQuery(ctx, q)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.Execute(w, data); err != nil {
http.Error(w, err.Error(), 500)
return
}
}
type compareData struct {
Q string
Error string
Benchstat safehtml.HTML
Groups []*resultGroup
Labels map[string]bool
CommonLabels benchfmt.Labels
}
// queryKeys returns the keys that are exact-matched by q.
func queryKeys(q string) map[string]bool {
out := make(map[string]bool)
for _, part := range query.SplitWords(q) {
// TODO(quentin): This func is shared with db.go; refactor?
i := strings.IndexFunc(part, func(r rune) bool {
return r == ':' || r == '>' || r == '<' || unicode.IsSpace(r) || unicode.IsUpper(r)
})
if i >= 0 && part[i] == ':' {
out[part[:i]] = true
}
}
return out
}
// elideKeyValues returns content, a benchmark format line, with the
// values of any keys in keys elided.
func elideKeyValues(content string, keys map[string]bool) string {
var end string
if i := strings.IndexFunc(content, unicode.IsSpace); i >= 0 {
content, end = content[:i], content[i:]
}
// Check for gomaxprocs value
if i := strings.LastIndex(content, "-"); i >= 0 {
_, err := strconv.Atoi(content[i+1:])
if err == nil {
if keys["gomaxprocs"] {
content, end = content[:i], "-*"+end
} else {
content, end = content[:i], content[i:]+end
}
}
}
parts := strings.Split(content, "/")
for i, part := range parts {
if equals := strings.Index(part, "="); equals >= 0 {
if keys[part[:equals]] {
parts[i] = part[:equals] + "=*"
}
} else if i == 0 {
if keys["name"] {
parts[i] = "Benchmark*"
}
} else if keys[fmt.Sprintf("sub%d", i)] {
parts[i] = "*"
}
}
return strings.Join(parts, "/") + end
}
// fetchCompareResults fetches the matching results for a given query string.
// The results will be grouped into one or more groups based on either the query string or heuristics.
func (a *App) fetchCompareResults(ctx context.Context, q string) ([]*resultGroup, error) {
// Parse query
prefix, queries := parseQueryString(q)
// Send requests
// TODO(quentin): Issue requests in parallel?
var groups []*resultGroup
var found int
for _, qPart := range queries {
keys := queryKeys(qPart)
group := &resultGroup{Q: qPart}
if prefix != "" {
qPart = prefix + " " + qPart
}
s, err := a.StorageClient.Query(ctx, qPart)
if err != nil {
return nil, err
}
res := benchfmt.NewReader(s)
for res.Next() {
result := res.Result()
result.Content = elideKeyValues(result.Content, keys)
group.add(result)
found++
}
err = res.Err()
s.Close()
if err != nil {
// TODO: If the query is invalid, surface that to the user.
return nil, err
}
groups = append(groups, group)
}
if found == 0 {
return nil, errors.New("no results matched the query string")
}
// Attempt to automatically split results.
if len(groups) == 1 {
group := groups[0]
// Matching a single CL -> split by filename
switch {
case len(group.LabelValues["cl"]) == 1 && len(group.LabelValues["ps"]) == 1 && len(group.LabelValues["upload-file"]) > 1:
groups = group.splitOn("upload-file")
// Matching a single upload with multiple files -> split by file
case len(group.LabelValues["upload"]) == 1 && len(group.LabelValues["upload-part"]) > 1:
groups = group.splitOn("upload-part")
}
}
return groups, nil
}
func (a *App) compareQuery(ctx context.Context, q string) *compareData {
if len(q) == 0 {
return &compareData{}
}
groups, err := a.fetchCompareResults(ctx, q)
if err != nil {
return &compareData{
Q: q,
Error: err.Error(),
}
}
// Compute benchstat
c := &benchstat.Collection{
AddGeoMean: true,
SplitBy: nil,
}
for _, label := range []string{"buildlet", "pkg", "goos", "goarch"} {
for _, g := range groups {
if len(g.LabelValues[label]) > 1 {
c.SplitBy = append(c.SplitBy, label)
break
}
}
}
for _, g := range groups {
c.AddResults(g.Q, g.results)
}
tableHTML := benchstat.SafeFormatHTML(c.Tables())
// Prepare struct for template.
labels := make(map[string]bool)
// commonLabels are the key: value of every label that has an
// identical value on every result.
commonLabels := make(benchfmt.Labels)
// Scan the first group for common labels.
for k, vs := range groups[0].LabelValues {
if len(vs) == 1 {
for v := range vs {
commonLabels[k] = v
}
}
}
// Remove any labels not common in later groups.
for _, g := range groups[1:] {
for k, v := range commonLabels {
if len(g.LabelValues[k]) != 1 || g.LabelValues[k][v] == 0 {
delete(commonLabels, k)
}
}
}
// List all labels present and not in commonLabels.
for _, g := range groups {
for k := range g.LabelValues {
if commonLabels[k] != "" {
continue
}
labels[k] = true
}
}
data := &compareData{
Q: q,
Benchstat: tableHTML,
Groups: groups,
Labels: labels,
CommonLabels: commonLabels,
}
return data
}
// textCompare is called if benchsave is requesting a text-only analysis.
func (a *App) textCompare(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
q := r.Form.Get("q")
groups, err := a.fetchCompareResults(ctx, q)
if err != nil {
// TODO(quentin): Should we serve this with a 500 or 404? This means the query was invalid or had no results.
fmt.Fprintf(w, "unable to analyze results: %v", err)
}
// Compute benchstat
c := new(benchstat.Collection)
for _, g := range groups {
c.AddResults(g.Q, g.results)
}
benchstat.FormatText(w, c.Tables())
}