// Copyright 2025 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. //go:build ignore // This program converts CSV calibration data printed by // // go test -run=Calibrate/Name -calibrate >file.csv // // into an SVG file. Invoke as: // // go run calibrate_graph.go file.csv >file.svg // // See calibrate.md for more details. package main import ( "bytes" "encoding/csv" "flag" "fmt" "log" "math" "os" "strconv" ) func usage() { fmt.Fprintf(os.Stderr, "usage: go run calibrate_graph.go file.csv >file.svg\n") os.Exit(2) } // A Point is an X, Y coordinate in the data being plotted. type Point struct { X, Y float64 } // A Graph is a graph to draw as SVG. type Graph struct { Title string // title above graph Geomean []Point // geomean line Lines [][]Point // normalized data lines XAxis string // x-axis label YAxis string // y-axis label Min Point // min point of data display Max Point // max point of data display } var yMax = flag.Float64("ymax", 1.2, "maximum y axis value") var alphaNorm = flag.Float64("alphanorm", 0.1, "alpha for a single norm line") func main() { flag.Usage = usage flag.Parse() if flag.NArg() != 1 { usage() } // Read CSV. It may be enclosed in // -- name.csv -- // ... // -- eof -- // framing, in which case remove the framing. fdata, err := os.ReadFile(flag.Arg(0)) if err != nil { log.Fatal(err) } if _, after, ok := bytes.Cut(fdata, []byte(".csv --\n")); ok { fdata = after } if before, _, ok := bytes.Cut(fdata, []byte("-- eof --\n")); ok { fdata = before } rd := csv.NewReader(bytes.NewReader(fdata)) rd.FieldsPerRecord = -1 records, err := rd.ReadAll() if err != nil { log.Fatal(err) } // Construct graph from loaded CSV. // CSV starts with metadata lines like // goos,darwin // and then has two tables of timings. // Each table looks like // size \ threshold,10,20,30,40 // 100,1,2,3,4 // 200,2,3,4,5 // 300,3,4,5,6 // 400,4,5,6,7 // 500,5,6,7,8 // The header line gives the threshold values and then each row // gives an input size and the timings for each threshold. // Omitted timings are empty strings and turn into infinities when parsing. // The first table gives raw nanosecond timings. // The second table gives timings normalized relative to the fastest // possible threshold for a given input size. // We only want the second table. // The tables are followed by a list of geomeans of all the normalized // timings for each threshold: // geomean,1.2,1.1,1.0,1.4 // We turn each normalized timing row into a line in the graph, // and we turn the geomean into an overlaid thick line. // The metadata is used for preparing the titles. g := &Graph{ YAxis: "Relative Slowdown", Min: Point{0, 1}, Max: Point{1, 1.2}, } meta := make(map[string]string) table := 0 // number of table headers seen var thresholds []float64 maxNorm := 0.0 for _, rec := range records { if len(rec) == 0 { continue } if len(rec) == 2 { meta[rec[0]] = rec[1] continue } if rec[0] == `size \ threshold` { table++ if table == 2 { thresholds = parseFloats(rec) g.Min.X = thresholds[0] g.Max.X = thresholds[len(thresholds)-1] } continue } if rec[0] == "geomean" { table = 3 // end of norms table geomeans := parseFloats(rec) g.Geomean = floatsToLine(thresholds, geomeans) continue } if table == 2 { if _, err := strconv.Atoi(rec[0]); err != nil { // size log.Fatalf("invalid table line: %q", rec) } norms := parseFloats(rec) if len(norms) > len(thresholds) { log.Fatalf("too many timings (%d > %d): %q", len(norms), len(thresholds), rec) } g.Lines = append(g.Lines, floatsToLine(thresholds, norms)) for _, y := range norms { maxNorm = max(maxNorm, y) } continue } } g.Max.Y = min(*yMax, math.Ceil(maxNorm*100)/100) g.XAxis = meta["calibrate"] + "Threshold" g.Title = meta["goos"] + "/" + meta["goarch"] + " " + meta["cpu"] os.Stdout.Write(g.SVG()) } // parseFloats parses rec[1:] as floating point values. // If a field is the empty string, it is represented as +Inf. func parseFloats(rec []string) []float64 { floats := make([]float64, 0, len(rec)-1) for _, v := range rec[1:] { if v == "" { floats = append(floats, math.Inf(+1)) continue } f, err := strconv.ParseFloat(v, 64) if err != nil { log.Fatalf("invalid record: %q (%v)", rec, err) } floats = append(floats, f) } return floats } // floatsToLine converts a sequence of floats into a line, ignoring missing (infinite) values. func floatsToLine(x, y []float64) []Point { var line []Point for i, yi := range y { if !math.IsInf(yi, 0) { line = append(line, Point{x[i], yi}) } } return line } const svgHeader = ` ` // Layout constants for drawing graph const ( DX = 600 // width of graphed data DY = 150 // height of graphed data ML = 80 // margin left MT = 30 // margin top MR = 10 // margin right MB = 50 // margin bottom PS = 14 // point size of text W = ML + DX + MR // width of overall graph H = MT + DY + MB // height of overall graph Tick = 5 // axis tick length ) // An SVGPoint is a point in the SVG image, in pixel units, // with Y increasing down the page. type SVGPoint struct { X, Y int } func (p SVGPoint) String() string { return fmt.Sprintf("%d,%d", p.X, p.Y) } // pt converts an x, y data value (such as from a Point) to an SVGPoint. func (g *Graph) pt(x, y float64) SVGPoint { return SVGPoint{ X: ML + int((x-g.Min.X)/(g.Max.X-g.Min.X)*DX), Y: H - MB - int((y-g.Min.Y)/(g.Max.Y-g.Min.Y)*DY), } } // SVG returns the SVG text for the graph. func (g *Graph) SVG() []byte { var svg bytes.Buffer fmt.Fprintf(&svg, svgHeader, W, H, PS, PS, *alphaNorm) // Draw data, clipped. fmt.Fprintf(&svg, "\n", g.pt(g.Min.X, g.Min.Y), g.pt(g.Max.X, g.Min.Y), g.pt(g.Max.X, g.Max.Y), g.pt(g.Min.X, g.Max.Y)) fmt.Fprintf(&svg, "\n") for _, line := range g.Lines { if len(line) == 0 { continue } fmt.Fprintf(&svg, "\n") } // Draw geomean. if len(g.Geomean) > 0 { line := g.Geomean fmt.Fprintf(&svg, "\n") } fmt.Fprintf(&svg, "\n") // Draw axes and major and minor tick marks. fmt.Fprintf(&svg, " 0.5 { yscale = 10 } for y := int(math.Ceil(g.Min.Y * yscale)); float64(y) <= g.Max.Y*yscale; y++ { if y%5 != 0 { fmt.Fprintf(&svg, " M %v l -%d,0", g.pt(g.Min.X, float64(y)/yscale), Tick) } else { fmt.Fprintf(&svg, " M %v l -%d,0", g.pt(g.Min.X, float64(y)/yscale), 2*Tick) } } fmt.Fprintf(&svg, "\"/>\n") // Draw tick labels on major marks. for x := int(math.Ceil(g.Min.X / xscale)); float64(x)*xscale <= g.Max.X; x++ { if x%5 == 0 { p := g.pt(float64(x)*xscale, g.Min.Y) fmt.Fprintf(&svg, "%d\n", p.X, p.Y+2*Tick+PS, x*int(xscale)) } } for y := int(math.Ceil(g.Min.Y * yscale)); float64(y) <= g.Max.Y*yscale; y++ { if y%5 == 0 { p := g.pt(g.Min.X, float64(y)/yscale) fmt.Fprintf(&svg, "%.2f\n", p.X-2*Tick-Tick, p.Y+PS/3, float64(y)/yscale) } } // Draw graph title and axis titles. fmt.Fprintf(&svg, "%s\n", ML+DX/2, MT-PS/3, g.Title) fmt.Fprintf(&svg, "%s\n", ML+DX/2, MT+DY+2*Tick+2*PS+PS/2, g.XAxis) fmt.Fprintf(&svg, "%s\n", ML-Tick-Tick-3*PS, MT+DY/2, g.YAxis) fmt.Fprintf(&svg, "\n") return svg.Bytes() }