Add an MVP dashboard of benchmark results at /dashboard. This dashboard
is heavily based on mknyszek@'s prototype in CL 385554.

Results from the past 7 days for a few hand-picked benchmarks are
fetched from Influx and sent to the frontend, where they are graphed
using d3.js.

For golang/go#48803.

Change-Id: Id6cc7c51afc5a6bf718559a93b7b1e9a18c4b9bf
Reviewed-on: https://go-review.googlesource.com/c/build/+/412136
Reviewed-by: Michael Knyszek <mknyszek@google.com>
Run-TryBot: Michael Pratt <mpratt@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
This commit is contained in:
Michael Pratt 2022-06-13 15:29:38 -04:00
Родитель 59e7a6bb02
Коммит 2e87f3ba77
9 изменённых файлов: 775 добавлений и 11 удалений

Просмотреть файл

@ -57,6 +57,7 @@ func (a *App) RegisterOnMux(mux *http.ServeMux) {
mux.HandleFunc("/trend", a.trend)
mux.HandleFunc("/cron/syncinflux", a.syncInflux)
mux.HandleFunc("/healthz", a.healthz)
a.dashboardRegisterOnMux(mux)
}
// search handles /search.

197
perf/app/dashboard.go Normal file
Просмотреть файл

@ -0,0 +1,197 @@
// Copyright 2022 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"
"embed"
"encoding/json"
"fmt"
"log"
"net/http"
"sort"
"time"
"github.com/influxdata/influxdb-client-go/v2/api"
"golang.org/x/build/internal/influx"
"golang.org/x/build/third_party/bandchart"
)
// /dashboard/ displays a dashboard of benchmark results over time for
// performance monitoring.
//go:embed dashboard/*
var dashboardFS embed.FS
// dashboardRegisterOnMux registers the dashboard URLs on mux.
func (a *App) dashboardRegisterOnMux(mux *http.ServeMux) {
mux.Handle("/dashboard/", http.FileServer(http.FS(dashboardFS)))
mux.Handle("/dashboard/third_party/bandchart/", http.StripPrefix("/dashboard/third_party/bandchart/", http.FileServer(http.FS(bandchart.FS))))
mux.HandleFunc("/dashboard/data.json", a.dashboardData)
}
// BenchmarkJSON contains the timeseries values for a single benchmark name +
// unit.
//
// We could try to shoehorn this into benchfmt.Result, but that isn't really
// the best fit for a graph.
type BenchmarkJSON struct {
Name string
Unit string
// These will be sorted by CommitDate.
Values []ValueJSON
}
type ValueJSON struct {
CommitHash string
CommitDate time.Time
// These are pre-formatted as percent change.
Low float64
Center float64
High float64
}
// fetch queries Influx to fill Values. Name and Unit must be set.
//
// WARNING: Name and Unit are not sanitized. DO NOT pass user input.
func (b *BenchmarkJSON) fetch(ctx context.Context, qc api.QueryAPI) error {
if b.Name == "" {
return fmt.Errorf("Name must be set")
}
if b.Unit == "" {
return fmt.Errorf("Unit must be set")
}
// TODO(prattmic): Adjust UI to comfortably display more than 7d of
// data.
query := fmt.Sprintf(`
from(bucket: "perf")
|> range(start: -7d)
|> filter(fn: (r) => r["_measurement"] == "benchmark-result")
|> filter(fn: (r) => r["name"] == "%s")
|> filter(fn: (r) => r["unit"] == "%s")
|> filter(fn: (r) => r["branch"] == "master")
|> filter(fn: (r) => r["goos"] == "linux")
|> filter(fn: (r) => r["goarch"] == "amd64")
|> pivot(columnKey: ["_field"], rowKey: ["_time"], valueColumn: "_value")
|> yield(name: "last")
`, b.Name, b.Unit)
ir, err := qc.Query(ctx, query)
if err != nil {
return fmt.Errorf("error performing query: %W", err)
}
for ir.Next() {
rec := ir.Record()
low, ok := rec.ValueByKey("low").(float64)
if !ok {
return fmt.Errorf("record %s low value got type %T want float64", rec, rec.ValueByKey("low"))
}
center, ok := rec.ValueByKey("center").(float64)
if !ok {
return fmt.Errorf("record %s center value got type %T want float64", rec, rec.ValueByKey("center"))
}
high, ok := rec.ValueByKey("high").(float64)
if !ok {
return fmt.Errorf("record %s high value got type %T want float64", rec, rec.ValueByKey("high"))
}
commit, ok := rec.ValueByKey("experiment-commit").(string)
if !ok {
return fmt.Errorf("record %s experiment-commit value got type %T want float64", rec, rec.ValueByKey("experiment-commit"))
}
b.Values = append(b.Values, ValueJSON{
CommitDate: rec.Time(),
CommitHash: commit,
Low: (low - 1) * 100,
Center: (center - 1) * 100,
High: (high - 1) * 100,
})
}
sort.Slice(b.Values, func(i, j int) bool {
return b.Values[i].CommitDate.Before(b.Values[j].CommitDate)
})
return nil
}
// search handles /dashboard/data.json.
//
// TODO(prattmic): Consider caching Influx results in-memory for a few mintures
// to reduce load on Influx.
func (a *App) dashboardData(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
start := time.Now()
defer func() {
log.Printf("Dashboard total query time: %s", time.Since(start))
}()
ifxc, err := a.influxClient(ctx)
if err != nil {
log.Printf("Error getting Influx client: %v", err)
http.Error(w, "Error connecting to Influx", 500)
return
}
defer ifxc.Close()
qc := ifxc.QueryAPI(influx.Org)
// Keep benchmarks with the same name grouped together, which is
// assumed by the JS.
//
// WARNING: Name and Unit are not sanitized. DO NOT pass user input.
benchmarks := []BenchmarkJSON{
{
Name: "Tile38WithinCircle100kmRequest",
Unit: "sec/op",
},
{
Name: "Tile38WithinCircle100kmRequest",
Unit: "p90-latency-sec",
},
{
Name: "Tile38WithinCircle100kmRequest",
Unit: "average-RSS-bytes",
},
{
Name: "Tile38WithinCircle100kmRequest",
Unit: "peak-RSS-bytes",
},
{
Name: "GoBuildKubelet",
Unit: "sec/op",
},
{
Name: "GoBuildKubeletLink",
Unit: "sec/op",
},
}
for i := range benchmarks {
b := &benchmarks[i]
// WARNING: Name and Unit are not sanitized. DO NOT pass user
// input.
if err := b.fetch(ctx, qc); err != nil {
log.Printf("Error fetching benchmark %s/%s: %v", b.Name, b.Unit, err)
http.Error(w, "Error fetching benchmark", 500)
return
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
e := json.NewEncoder(w)
e.SetIndent("", "\t")
e.Encode(benchmarks)
}

Просмотреть файл

@ -0,0 +1,107 @@
<!--
Copyright 2022 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.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<title>Go Performance Dashboard</title>
<link rel="icon" href="https://go.dev/favicon.ico"/>
<link rel="stylesheet" href="./static/style.css"/>
<script src="https://ajax.googleapis.com/ajax/libs/d3js/7.4.2/d3.min.js"></script>
<script src="./third_party/bandchart/bandchart.js"></script>
</head>
<body class="Dashboard">
<header class="Dashboard-topbar">
<h1>
<a href="./">Go Performance Dashboard</a>
</h1>
<nav>
<ul>
<li><a href="https://build.golang.org">Build Dashboard</a></li>
</ul>
</nav>
</header>
<form autocomplete="off" action="./">
<nav class="Dashboard-controls">
<div class="Dashboard-search">
<input id="benchmarkInput" type="text" name="benchmark" placeholder="Type benchmark name...">
</div>
<input type="submit">
</nav>
</form>
<script>
</script>
<div id="dashboard"></div>
<script>
function addContent(name, benchmarks) {
let dashboard = document.getElementById("dashboard");
if (name == "" || name == null || name == undefined) {
// All benchmarks.
// TODO(prattmic): Replace with a simpler overview?
} else {
// Filter to specified benchmark.
benchmarks = benchmarks.filter(function(b) {
return b.Name == name;
});
if (benchmarks.length == 0) {
let title = document.createElement("h2");
title.classList.add("Dashboard-title");
title.innerHTML = "Benchmark \"" + name + "\" not found.";
dashboard.appendChild(title);
return;
}
}
let prevName = "";
let grid = null;
for (const b in benchmarks) {
const bench = benchmarks[b];
if (bench.Name != prevName) {
prevName = bench.Name;
let title = document.createElement("h2");
title.classList.add("Dashboard-title");
title.innerHTML = bench.Name;
dashboard.appendChild(title);
grid = document.createElement("grid");
grid.classList.add("Dashboard-grid");
dashboard.appendChild(grid);
}
let item = document.createElement("div");
item.classList.add("Dashboard-grid-item");
item.appendChild(BandChart(bench.Values, {
unit: bench.Unit,
}));
grid.appendChild(item);
}
}
let benchmark = (new URLSearchParams(window.location.search)).get('benchmark');
fetch('./data.json')
.then(response => response.json())
.then(function(benchmarks) {
// Convert CommitDate to a proper date.
benchmarks.forEach(function(b) {
b.Values.forEach(function(v) {
v.CommitDate = new Date(v.CommitDate);
});
});
addContent(benchmark, benchmarks);
});
</script>
</body>
</html>

Просмотреть файл

@ -0,0 +1,141 @@
/*!
* Copyright 2022 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.
*/
* {
box-sizing: border-box;
}
body {
color: #222;
font-family: sans-serif;
margin: 0;
padding: 10px;
}
h1,
h2,
h1 > a,
h2 > a,
h1 > a:visited,
h2 > a:visited {
color: #375eab;
}
h1 {
font-size: 24px;
}
h2 {
font-size: 20px;
}
h1 > a,
h2 > a {
display: none;
text-decoration: none;
}
h1:hover > a,
h2:hover > a {
display: inline;
}
h1 > a:hover,
h2 > a:hover {
text-decoration: underline;
}
pre {
font-family: monospace;
font-size: 9pt;
}
header {
background: #e0ebf5;
margin: -10px -10px 0 -10px;
padding: 10px 10px;
}
header h1 {
display: inline;
margin: 0;
padding-top: 5px;
}
header h1 a {
display: initial;
}
header nav {
display: inline-block;
margin-left: 20px;
}
header nav ul {
list-style: none;
margin: 0;
padding: 0;
}
header nav ul li {
display: inline-block;
}
header nav a {
background: #375eab;
border: 1px solid #375eab;
border-radius: 5px;
color: white;
display: inline-block;
font-size: 16px;
margin: 0;
margin-right: 5px;
padding: 10px;
text-decoration: none;
}
.Dashboard {
margin: 0;
padding: 0;
}
.Dashboard-topbar {
margin: 0;
padding: 0.625rem 0.625rem;
}
.Dashboard-title {
background: #e0ebf5;
padding: 0.125rem 0.3125rem;
}
.Dashboard-controls {
padding: 0.5rem;
}
.Dashboard-search {
display: inline-block;
width: 300px;
}
input {
border: 1px solid transparent;
background-color: #f4f4f4;
padding: 10px;
font-size: 16px;
}
input[type=text] {
background-color: #f4f4f4;
width: 100%;
}
input[type=submit] {
background: #375eab;
border: 1px solid #375eab;
border-radius: 5px;
color: white;
display: inline-block;
font-size: 16px;
margin: 0;
margin-right: 5px;
padding: 10px;
text-decoration: none;
}
.Dashboard-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-start;
}
.Dashboard-grid-item {
padding: 8px;
}

Просмотреть файл

@ -27,6 +27,19 @@ const (
backfillWindow = 30 * 24 * time.Hour // 30 days.
)
func (a *App) influxClient(ctx context.Context) (influxdb2.Client, error) {
if a.InfluxHost == "" {
return nil, fmt.Errorf("Influx host unknown (set INFLUX_HOST?)")
}
token, err := a.findInfluxToken(ctx)
if err != nil {
return nil, fmt.Errorf("error finding Influx token: %w", err)
}
return influxdb2.NewClient(a.InfluxHost, token), nil
}
// syncInflux handles /cron/syncinflux, which updates an InfluxDB instance with
// the latest data from perfdata.golang.org (i.e. storage), or backfills it.
func (a *App) syncInflux(w http.ResponseWriter, r *http.Request) {
@ -40,21 +53,12 @@ func (a *App) syncInflux(w http.ResponseWriter, r *http.Request) {
}
}
if a.InfluxHost == "" {
s := "Influx host unknown (set INFLUX_HOST?)"
log.Printf(s)
http.Error(w, s, 500)
return
}
token, err := a.findInfluxToken(ctx)
ifxc, err := a.influxClient(ctx)
if err != nil {
log.Printf("Error finding Influx token: %v", err)
log.Printf("Error getting Influx client: %v", err)
http.Error(w, err.Error(), 500)
return
}
ifxc := influxdb2.NewClient(a.InfluxHost, token)
defer ifxc.Close()
log.Printf("Connecting to influx...")

Просмотреть файл

@ -39,6 +39,8 @@ func main() {
mux := http.NewServeMux()
app.RegisterOnMux(mux)
log.Printf("Serving...")
ctx := context.Background()
log.Fatal(https.ListenAndServe(ctx, mux))
}

13
third_party/bandchart/LICENSE поставляемый Normal file
Просмотреть файл

@ -0,0 +1,13 @@
Copyright 2018–2021 Observable, Inc.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

286
third_party/bandchart/bandchart.js поставляемый Normal file
Просмотреть файл

@ -0,0 +1,286 @@
// Copyright 2021 Observable, Inc.
// Released under the ISC license.
// https://observablehq.com/@d3/band-chart
function BandChart(data, {
defined,
marginTop = 50, // top margin, in pixels
marginRight = 15, // right margin, in pixels
marginBottom = 50, // bottom margin, in pixels
marginLeft = 30, // left margin, in pixels
width = 480, // outer width, in pixels
height = 240, // outer height, in pixels
unit,
} = {}) {
// Compute values.
const C = d3.map(data, d => d.CommitHash);
const X = d3.map(data, d => d.CommitDate);
const Y = d3.map(data, d => d.Center);
const Y1 = d3.map(data, d => d.Low);
const Y2 = d3.map(data, d => d.High);
const I = d3.range(X.length);
if (defined === undefined) defined = (d, i) => !isNaN(X[i]) && !isNaN(Y1[i]) && !isNaN(Y2[i]);
const D = d3.map(data, defined);
const xRange = [marginLeft, width - marginRight]; // [left, right]
const yRange = [height - marginBottom, marginTop]; // [bottom, top]
// Compute default domains.
let yDomain = d3.nice(...d3.extent([...Y1, ...Y2]), 10);
// Don't show <2.5% up-close because it just looks extremely noisy.
const minYDomain = [-2.5, 2.5];
if (yDomain[0] > minYDomain[0]) {
yDomain[0] = minYDomain[0];
}
if (yDomain[1] < minYDomain[1]) {
yDomain[1] = minYDomain[1];
}
// Construct scales and axes.
const xOrdTicks = d3.range(xRange[0], xRange[1], (xRange[1]-xRange[0])/(X.length-1));
xOrdTicks.push(xRange[1]);
const xScale = d3.scaleOrdinal(X, xOrdTicks);
const yScale = d3.scaleLinear(yDomain, yRange);
const xAxis = d3.axisBottom(xScale).tickSizeOuter(0).tickValues(d3.map(C, c => c.slice(0, 7)));
const yAxis = d3.axisLeft(yScale).ticks(height / 40);
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto; height: intrinsic;");
svg.append("g")
.attr("transform", `translate(${marginLeft},0)`)
.call(yAxis)
.call(g => g.select(".domain").remove())
.call(g => g.selectAll(".tick line").clone()
.attr("x2", width - marginLeft - marginRight)
.attr("stroke-opacity", 0.1))
.call(g => g.append("text")
.attr("x", (xRange[1]-xRange[0])/2)
.attr("y", 40)
.attr("fill", "currentColor")
.attr("text-anchor", "middle")
.attr("font-size", "20px")
.attr("font-weight", "bold")
.text(unit));
const defs = svg.append("defs")
const maxHalfColorPercent = 10;
const maxHalfColorOpacity = 0.25;
// Draw top half.
const goodColor = "blue";
const badColor = "red";
// By default, lower is better.
var bottomColor = goodColor;
var topColor = badColor;
const higherIsBetter = {
"B/s": true,
"ops/s": true
};
if (unit in higherIsBetter) {
bottomColor = badColor;
topColor = goodColor;
}
// IDs, even within SVGs, are shared across the entire page. (what?)
// So, at least try to avoid a collision.
const gradientIDSuffix = Math.random()*10000000.0;
const topGradient = defs.append("linearGradient")
.attr("id", "topGradient"+gradientIDSuffix)
.attr("x1", "0%")
.attr("x2", "0%")
.attr("y1", "100%")
.attr("y2", "0%");
topGradient.append("stop")
.attr("offset", "0%")
.style("stop-color", topColor)
.style("stop-opacity", 0);
let topGStopOpacity = maxHalfColorOpacity;
let topGOffsetPercent = 100.0;
if (yDomain[1] > maxHalfColorPercent) {
topGOffsetPercent *= maxHalfColorPercent/yDomain[1];
} else {
topGStopOpacity *= yDomain[1]/maxHalfColorPercent;
}
topGradient.append("stop")
.attr("offset", topGOffsetPercent+"%")
.style("stop-color", topColor)
.style("stop-opacity", topGStopOpacity);
const bottomGradient = defs.append("linearGradient")
.attr("id", "bottomGradient"+gradientIDSuffix)
.attr("x1", "0%")
.attr("x2", "0%")
.attr("y1", "0%")
.attr("y2", "100%");
bottomGradient.append("stop")
.attr("offset", "0%")
.style("stop-color", bottomColor)
.style("stop-opacity", 0);
let bottomGStopOpacity = maxHalfColorOpacity;
let bottomGOffsetPercent = 100.0;
if (yDomain[0] < -maxHalfColorPercent) {
bottomGOffsetPercent *= -maxHalfColorPercent/yDomain[0];
} else {
bottomGStopOpacity *= -yDomain[0]/maxHalfColorPercent;
}
bottomGradient.append("stop")
.attr("offset", bottomGOffsetPercent+"%")
.style("stop-color", bottomColor)
.style("stop-opacity", bottomGStopOpacity);
// Top half color.
svg.append("rect")
.attr("fill", "url(#topGradient"+gradientIDSuffix+")")
.attr("x", xRange[0])
.attr("y", yScale(yDomain[1]))
.attr("width", xRange[1] - xRange[0])
.attr("height", (yDomain[1]/(yDomain[1]-yDomain[0]))*(height-marginTop-marginBottom));
// Bottom half color.
svg.append("rect")
.attr("fill", "url(#bottomGradient"+gradientIDSuffix+")")
.attr("x", xRange[0])
.attr("y", yScale(0))
.attr("width", xRange[1] - xRange[0])
.attr("height", (-yDomain[0]/(yDomain[1]-yDomain[0]))*(height-marginTop-marginBottom));
// Create CI area.
const area = d3.area()
.defined(i => D[i])
.curve(d3.curveLinear)
.x(i => xScale(X[i]))
.y0(i => yScale(Y1[i]))
.y1(i => yScale(Y2[i]));
svg.append("path")
.attr("fill", "black")
.attr("opacity", 0.1)
.attr("d", area(I));
// Add X axis.
svg.append("g")
.attr("transform", `translate(0,${height - marginBottom})`)
.call(xAxis)
.call(g => g.select(".domain").remove())
.selectAll("text")
.attr("y", 6)
.attr("x", -42)
.attr("transform", "rotate(315)")
.style("text-anchor", "start");
// Create center line.
const line = d3.line()
.defined(i => D[i])
.x(i => xScale(X[i]))
.y(i => yScale(Y[i]))
svg.append("path")
.attr("fill", "none")
.attr("stroke", "#375eab")
.attr("stroke-width", 3)
.attr("d", line(I))
// Create dots.
svg.append("g")
.attr("fill", "#375eab")
.attr("stroke", "#e0ebf5")
.attr("stroke-width", 1)
.selectAll("circle")
.data(I)
.join("circle")
.attr("cx", i => xScale(X[i]))
.attr("cy", i => yScale(Y[i]))
.attr("r", 4);
// Divide the chart into columns and apply links and hover actions to them.
svg.append("g")
.attr("stroke", "#2074A0")
.attr("stroke-opacity", 0)
.attr("fill", "none")
.selectAll("path")
.data(I)
.join("a")
.attr("xlink:href", (d, i) => "https://go.googlesource.com/go/+show/"+C[i])
.append("rect")
.attr("pointer-events", "all")
.attr("x", (d, i) => {
if (i == 0) {
return xOrdTicks[i];
}
return xOrdTicks[i-1]+(xOrdTicks[i]-xOrdTicks[i-1])/2;
})
.attr("y", marginTop)
.attr("width", (d, i) => {
if (i == 0 || i == X.length-1) {
return (xOrdTicks[1]-xOrdTicks[0]) / 2;
}
return xOrdTicks[1]-xOrdTicks[0];
})
.attr("height", height-marginTop-marginBottom)
.on("mouseover", (d, i) => {
svg.append('a')
.attr("class", "tooltip")
.call(g => g.append('line')
.attr("x1", xScale(X[i]))
.attr("y1", yRange[0])
.attr("x2", xScale(X[i]))
.attr("y2", yRange[1])
.attr("stroke", "black")
.attr("stroke-width", 1)
.attr("stroke-dasharray", 2)
.attr("opacity", 0.5)
)
.call(g => g.append('text')
.attr("x", (() => {
let base = xScale(X[i]);
if (base < marginLeft+100) {
base += 10;
} else if (base > width-marginRight-100) {
base -= 10;
}
return base;
})())
.attr("y", (() => {
let base = yScale(Y[i]);
if (base < marginTop+100) {
base += 30;
} else if (base > height-marginBottom-100) {
base -= 30;
}
return base;
}))
.attr("pointer-events", "none")
.attr("fill", "currentColor")
.attr("text-anchor", (() => {
let base = xScale(X[i]);
if (base < marginLeft+100) {
return "start";
} else if (base > width-marginRight-100) {
return "end";
}
return "middle";
})())
.attr("font-family", "sans-serif")
.attr("font-size", 12)
.text(C[i].slice(0, 7) + " ("
+ Intl.DateTimeFormat([], {
dateStyle: "long",
timeStyle: "short"
}).format(X[i])
+ ")")
)
})
.on("mouseout", () => svg.selectAll('.tooltip').remove());
return svg.node();
}

13
third_party/bandchart/fs.go поставляемый Normal file
Просмотреть файл

@ -0,0 +1,13 @@
// Copyright 2022 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 bandchart provides an embedded bandchart.js.
package bandchart
import (
"embed"
)
//go:embed bandchart.js
var FS embed.FS