| // 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 benchseries | |
| import ( | |
| "fmt" | |
| "image/color" | |
| "math" | |
| "os" | |
| "path/filepath" | |
| "sort" | |
| "strings" | |
| "gonum.org/v1/plot/vg/draw" | |
| "gonum.org/v1/plot/vg/vgimg" | |
| // "gonum.org/v1/plot/vg/vgpdf" | |
| // "gonum.org/v1/plot/vg/vgsvg" | |
| "gonum.org/v1/plot" | |
| "gonum.org/v1/plot/plotter" | |
| "gonum.org/v1/plot/vg" | |
| ) | |
| type ChartOptions int | |
| type Point struct { | |
| numHash, denHash string | |
| values plotter.Values | |
| changes []float64 | |
| changeBVID []benchValID | |
| } | |
| // Because there are holes in that data, the benchmark index can be larger than the valueIndex | |
| type benchValID struct { | |
| benchIndex, valueIndex int | |
| } | |
| const pointRad = 5 | |
| func Chart(cs []*ComparisonSeries, pngDir, pdfDir, svgDir string, logScale bool, threshold float64, boring bool) { | |
| doDir := func(s string) { | |
| if s != "" { | |
| os.MkdirAll(s, 0777) | |
| } | |
| } | |
| doDir(pngDir) | |
| doDir(pdfDir) | |
| doDir(svgDir) | |
| for _, g := range cs { | |
| var allValues []float64 | |
| // When boring runs are omitted, this is where the interesting parts go. | |
| selectedPoints := []*Point{} | |
| // select a subset of the points, inserting "..." whenever there is a gap. | |
| for i, s := range g.Series { | |
| values := make(plotter.Values, 0, len(g.Benchmarks)) | |
| changes := make([]float64, 0, len(g.Benchmarks)) | |
| changeBenches := make([]benchValID, 0, len(g.Benchmarks)) // which benchmarks changed? | |
| for j := range g.Benchmarks { | |
| sum := g.Summaries[i][j] | |
| if sum.Defined() { | |
| ch := math.NaN() | |
| v := sum.Center | |
| if math.IsInf(v, 0) { | |
| continue | |
| } | |
| if i > 0 { | |
| psum := g.Summaries[i-1][j] | |
| if psum.Defined() { | |
| ch = psum.HeurOverlap(sum, threshold) | |
| } | |
| } | |
| changes = append(changes, ch) | |
| changeBenches = append(changeBenches, benchValID{j, len(values)}) | |
| values = append(values, v) | |
| allValues = append(allValues, v) | |
| } | |
| } | |
| hp := g.HashPairs[s] | |
| selectedPoints = append(selectedPoints, &Point{numHash: hp.NumHash, denHash: hp.DenHash, values: values, changes: changes, changeBVID: changeBenches}) | |
| } | |
| if len(selectedPoints) == 0 { | |
| continue | |
| } | |
| // Want lines that grab most of the data; it's outliers we worry about | |
| sort.Float64s(allValues) | |
| lav := len(allValues) | |
| const Nth = 25 | |
| lapNth := (lav + (Nth / 2)) / Nth | |
| minLine := allValues[lapNth] | |
| maxLine := allValues[lav-lapNth-1] | |
| pl := plot.New() | |
| pl.Title.Text = g.Unit | |
| pl.Title.TextStyle.Font.Size = 40 | |
| pl.Y.Label.Text = "tip measure / baseline measure" | |
| if logScale { | |
| pl.Y.Scale = plot.LogScale{} | |
| // TODO perhaps these are not the best lines for a log scale. | |
| pl.Y.Tick.Marker = ratioLines(minLine, maxLine, allValues[0], allValues[lav-1]) | |
| } else { | |
| pl.Y.Tick.Marker = ratioLines(minLine, maxLine, allValues[0], allValues[lav-1]) | |
| } | |
| pl.Y.Tick.Label.Font.Size = 20 | |
| grid := plotter.NewGrid() | |
| grid.Vertical.Color = nil | |
| pl.Add(grid) | |
| w := vg.Points(10) | |
| var nominalX []string | |
| var boxes []plot.Plotter | |
| for i, sp := range selectedPoints { | |
| perm := absSortedPermFor(sp.changes) | |
| rmsChange := norm(sp.changes, 2) | |
| sp.changes = permute(sp.changes, perm) | |
| l := len(sp.changes) | |
| movers := make(map[int]bool) | |
| moves := []directedColor{} | |
| for k := 1; k <= 5; k++ { | |
| if l-k < 0 { | |
| break | |
| } | |
| ch := sp.changes[l-k] | |
| c := math.Abs(ch) | |
| noteMove := func(clr color.Color) { | |
| index := sp.changeBVID[perm[l-k]].valueIndex | |
| bi := sp.changeBVID[perm[l-k]].benchIndex | |
| prevIndex := -1 | |
| for _, psp := range selectedPoints[i-1].changeBVID { | |
| if psp.benchIndex == bi { | |
| prevIndex = psp.valueIndex | |
| } | |
| } | |
| // it should not be the case that a move was recorded, yet there was no match for prevIndex | |
| movers[index] = true | |
| moves = append(moves, directedColor{prev: selectedPoints[i-1].values.Value(prevIndex), index: index, change: ch, clr: clr}) | |
| } | |
| if c >= 100 { | |
| noteMove(green(0xff)) | |
| } else if c > 5 { | |
| noteMove(red(0xff)) | |
| } else if c > 4 { | |
| noteMove(purple(0xff)) | |
| } else if c > 3 { | |
| noteMove(blue(0xff)) | |
| } else { | |
| break | |
| } | |
| } | |
| p := sp.values | |
| b, err := MyNewBoxPlot(w, float64(i), p, moves, movers) | |
| if err != nil { | |
| panic(err) | |
| } | |
| b.bp.BoxStyle.Color = color.Black | |
| b.bp.GlyphStyle.Radius = pointRad | |
| boxes = append(boxes, b) | |
| if rmsChange > 6 { | |
| b.bp.BoxStyle.Color = red(0xff) | |
| b.bp.FillColor = red(0x50) | |
| } else if rmsChange > 4 { | |
| b.bp.BoxStyle.Color = purple(0xFF) | |
| b.bp.FillColor = purple(0x50) | |
| } else if percentile(sp.changes, 1) > 4 { | |
| b.bp.BoxStyle.Color = blue(0xff) | |
| b.bp.FillColor = blue(0x50) | |
| } | |
| label := sp.numHash + "/" + sp.denHash | |
| nominalX = append(nominalX, label) | |
| } | |
| pl.Add(boxes...) | |
| pl.NominalX(nominalX...) | |
| pl.X.Tick.Width = vg.Points(0.5) | |
| pl.X.Tick.Length = vg.Points(8) | |
| pl.X.Tick.Label.Rotation = -math.Pi / 8 | |
| pl.X.Tick.Label.YAlign = draw.YTop | |
| pl.X.Tick.Label.XAlign = draw.XLeft | |
| pl.X.Tick.Label.Font.Size = 15 | |
| // Force the unit ratio onto the graph to ensure there is a scale. | |
| if pl.Y.Min > 1 { | |
| pl.Y.Min = 1 | |
| } | |
| if pl.Y.Max < 1 { | |
| pl.Y.Max = 1 | |
| } | |
| // Heuristic width and height | |
| width := 1.5 * float64(2+len(selectedPoints)) | |
| height := width / 3 | |
| if pl.Y.Max > 1 && pl.Y.Max-1 > 2*(math.Max(maxLine, minLine)-1) || | |
| pl.Y.Min < 1 && 1-pl.Y.Min > 2*(1-math.Min(maxLine, minLine)) { | |
| height = height * 1.5 | |
| } | |
| if height < 5 { | |
| height = 5 | |
| } | |
| dpi := 300 | |
| // // Override heuristics if demanded | |
| // if *flagWidth != 0 { | |
| // width = *flagWidth | |
| // } | |
| // if *flagHeight != 0 { | |
| // height = *flagHeight | |
| // } | |
| // Scale down dpi to conform to twitter limits | |
| initialWidth := float64(dpi) * width / 2.54 | |
| if initialWidth > 8190 { | |
| dpi = int(math.Trunc(float64(dpi) * 8190 / initialWidth)) | |
| } | |
| //fmt.Printf("%s: W=%f, H=%f, DPI=%d, PYM=%f, PYm=%f, Ml=%f, ml=%f\n", filename, width, height, dpi, | |
| // p.Y.Max, p.Y.Min, maxLine, minLine) | |
| filename := strings.ReplaceAll(g.Unit, "/", "-per-") | |
| do := func(dir, sfx string, can vg.CanvasWriterTo) { | |
| file := filepath.Join(dir, filename) + "." + sfx | |
| f, err := os.Create(file) | |
| if err != nil { | |
| panic(err) | |
| } | |
| pl.Draw(draw.New(can)) | |
| _, err = can.WriteTo(f) | |
| if err != nil { | |
| panic(err) | |
| } | |
| f.Close() | |
| } | |
| if pngDir != "" { | |
| do(pngDir, "png", vgimg.PngCanvas{Canvas: vgimg.NewWith(vgimg.UseWH(vg.Length(width)*vg.Centimeter, vg.Length(height)*vg.Centimeter), | |
| vgimg.UseDPI(dpi), vgimg.UseBackgroundColor(color.White))}) | |
| } | |
| // if pdfDir != "" { | |
| // do(pdfDir, "pdf", vgpdf.Canvas{Canvas: vgimg.NewWith(vgimg.UseWH(vg.Length(width)*vg.Centimeter, vg.Length(height)*vg.Centimeter), | |
| // vgimg.UseDPI(dpi), vgimg.UseBackgroundColor(color.White))}) | |
| // } | |
| // if svgDir != "" { | |
| // do(svgDir, "svg", vgsvg.Canvas{Canvas: vgimg.NewWith(vgimg.UseWH(vg.Length(width)*vg.Centimeter, vg.Length(height)*vg.Centimeter), | |
| // vgimg.UseDPI(dpi), vgimg.UseBackgroundColor(color.White))}) | |
| // } | |
| // Other formats, including default PNG | |
| //if err := p.Save(20*vg.Inch, 5*vg.Inch, filepath.Join(dir, filename)+".png"); err != nil { | |
| // panic(err) | |
| //} | |
| //if err := p.Save(20*vg.Inch, 5*vg.Inch, filepath.Join(dir, filename)+".svg"); err != nil { | |
| // panic(err) | |
| //} | |
| //if err := p.Save(20*vg.Inch, 5*vg.Inch, filepath.Join(dir, filename)+".pdf"); err != nil { | |
| // panic(err) | |
| //} | |
| } | |
| } | |
| func red(alpha uint8) color.Color { | |
| return color.NRGBA{0xFF, 0, 0, alpha} | |
| } | |
| func green(alpha uint8) color.Color { | |
| return color.NRGBA{0, 0xFF, 0, alpha} | |
| } | |
| func blue(alpha uint8) color.Color { | |
| return color.NRGBA{0, 0, 0xFF, alpha} | |
| } | |
| func purple(alpha uint8) color.Color { | |
| return color.NRGBA{0x99, 0, 0xFF, alpha} | |
| } | |
| type directedColor struct { | |
| prev float64 | |
| index int | |
| change float64 | |
| clr color.Color | |
| } | |
| type MyBoxPlot struct { | |
| bp *plotter.BoxPlot | |
| movers map[int]bool | |
| moves []directedColor | |
| } | |
| func MyNewBoxPlot(w vg.Length, loc float64, values plotter.Valuer, moves []directedColor, movers map[int]bool) (*MyBoxPlot, error) { | |
| b, err := plotter.NewBoxPlot(w, loc, values) | |
| if err != nil { | |
| return nil, err | |
| } | |
| return &MyBoxPlot{bp: b, moves: moves, movers: movers}, nil | |
| } | |
| // Plot draws the BoxPlot on Canvas c and Plot plt. | |
| func (p *MyBoxPlot) Plot(c draw.Canvas, plt *plot.Plot) { | |
| b := p.bp | |
| trX, trY := plt.Transforms(&c) | |
| x := trX(b.Location) | |
| px := trX(b.Location - 1) | |
| if !c.ContainsX(x) { | |
| return | |
| } | |
| x += b.Offset | |
| px += b.Offset | |
| med := trY(b.Median) | |
| q1 := trY(b.Quartile1) | |
| q3 := trY(b.Quartile3) | |
| aLow := trY(b.AdjLow) | |
| aHigh := trY(b.AdjHigh) | |
| pts := []vg.Point{ | |
| {X: x - b.Width/2, Y: q1}, | |
| {X: x - b.Width/2, Y: q3}, | |
| {X: x + b.Width/2, Y: q3}, | |
| {X: x + b.Width/2, Y: q1}, | |
| {X: x - b.Width/2 - b.BoxStyle.Width/2, Y: q1}, | |
| } | |
| box := c.ClipLinesY(pts) | |
| if b.FillColor != nil { | |
| c.FillPolygon(b.FillColor, c.ClipPolygonY(pts)) | |
| } | |
| c.StrokeLines(b.BoxStyle, box...) | |
| medLine := c.ClipLinesY([]vg.Point{ | |
| {X: x - b.Width/2, Y: med}, | |
| {X: x + b.Width/2, Y: med}, | |
| }) | |
| c.StrokeLines(b.MedianStyle, medLine...) | |
| cap := b.CapWidth / 2 | |
| whisks := c.ClipLinesY( | |
| []vg.Point{{X: x, Y: q3}, {X: x, Y: aHigh}}, | |
| []vg.Point{{X: x - cap, Y: aHigh}, {X: x + cap, Y: aHigh}}, | |
| []vg.Point{{X: x, Y: q1}, {X: x, Y: aLow}}, | |
| []vg.Point{{X: x - cap, Y: aLow}, {X: x + cap, Y: aLow}}, | |
| ) | |
| c.StrokeLines(b.WhiskerStyle, whisks...) | |
| for _, out := range b.Outside { | |
| y := trY(b.Value(out)) | |
| if c.ContainsY(y) { | |
| c.DrawGlyphNoClip(b.GlyphStyle, vg.Point{X: x, Y: y}) | |
| } | |
| } | |
| for _, dc := range p.moves { | |
| clr := dc.clr | |
| y := trY(b.Value(dc.index)) | |
| py := trY(dc.prev) | |
| if c.ContainsY(y) && c.ContainsY(py) { | |
| c.SetLineStyle(draw.LineStyle{Color: clr, Width: vg.Points(1)}) | |
| p := make(vg.Path, 0, 3) | |
| p.Move(vg.Point{X: px, Y: py}) | |
| p.Line(vg.Point{X: x, Y: y}) | |
| c.Stroke(p) | |
| } | |
| } | |
| } | |
| func (b *MyBoxPlot) DataRange() (float64, float64, float64, float64) { return b.bp.DataRange() } | |
| func (b *MyBoxPlot) GlyphBoxes(plt *plot.Plot) []plot.GlyphBox { return b.bp.GlyphBoxes(plt) } | |
| func (b *MyBoxPlot) OutsideLabels(labels plotter.Labeller) (*plotter.Labels, error) { | |
| return b.bp.OutsideLabels(labels) | |
| } | |
| const ( | |
| cosπover4 = vg.Length(.707106781202420) | |
| sinπover6 = vg.Length(.500000000025921) | |
| cosπover6 = vg.Length(.866025403769473) | |
| ) | |
| // CrossGlyph is a glyph that draws a big X. | |
| // this version draws a heavier X. | |
| type CrossGlyph struct{} | |
| // DrawGlyph implements the Glyph interface. | |
| func (CrossGlyph) DrawGlyph(c *draw.Canvas, sty draw.GlyphStyle, pt vg.Point) { | |
| c.SetLineStyle(draw.LineStyle{Color: sty.Color, Width: vg.Points(1)}) | |
| r := sty.Radius * cosπover4 | |
| p := make(vg.Path, 0, 2) | |
| p.Move(vg.Point{X: pt.X - r, Y: pt.Y - r}) | |
| p.Line(vg.Point{X: pt.X + r, Y: pt.Y + r}) | |
| c.Stroke(p) | |
| p = p[:0] | |
| p.Move(vg.Point{X: pt.X - r, Y: pt.Y + r}) | |
| p.Line(vg.Point{X: pt.X + r, Y: pt.Y - r}) | |
| c.Stroke(p) | |
| } | |
| type TriDown struct{} | |
| // DrawGlyph implements the Glyph interface. | |
| func (TriDown) DrawGlyph(c *draw.Canvas, sty draw.GlyphStyle, pt vg.Point) { | |
| c.SetLineStyle(draw.LineStyle{Color: sty.Color, Width: vg.Points(1)}) | |
| r := sty.Radius * cosπover4 | |
| p := make(vg.Path, 0, 3) | |
| p.Move(vg.Point{X: pt.X - r, Y: pt.Y + r}) | |
| p.Line(vg.Point{X: pt.X, Y: pt.Y - r}) | |
| c.Stroke(p) | |
| p = p[:0] | |
| p.Move(vg.Point{X: pt.X + r, Y: pt.Y + r}) | |
| p.Line(vg.Point{X: pt.X, Y: pt.Y - r}) | |
| c.Stroke(p) | |
| p = p[:0] | |
| p.Move(vg.Point{X: pt.X - r, Y: pt.Y}) | |
| p.Line(vg.Point{X: pt.X + r, Y: pt.Y}) | |
| c.Stroke(p) | |
| } | |
| type TriUp struct{} | |
| // DrawGlyph implements the Glyph interface. | |
| func (TriUp) DrawGlyph(c *draw.Canvas, sty draw.GlyphStyle, pt vg.Point) { | |
| c.SetLineStyle(draw.LineStyle{Color: sty.Color, Width: vg.Points(1)}) | |
| r := sty.Radius * cosπover4 | |
| p := make(vg.Path, 0, 3) | |
| p.Move(vg.Point{X: pt.X - r, Y: pt.Y - r}) | |
| p.Line(vg.Point{X: pt.X, Y: pt.Y + r}) | |
| c.Stroke(p) | |
| p = p[:0] | |
| p.Move(vg.Point{X: pt.X + r, Y: pt.Y - r}) | |
| p.Line(vg.Point{X: pt.X, Y: pt.Y + r}) | |
| c.Stroke(p) | |
| p = p[:0] | |
| p.Move(vg.Point{X: pt.X - r, Y: pt.Y}) | |
| p.Line(vg.Point{X: pt.X + r, Y: pt.Y}) | |
| c.Stroke(p) | |
| } | |
| type Lines struct { | |
| ticks []plot.Tick | |
| } | |
| // roundish finds a roundish fraction less than x, and the number of digits for formatting. | |
| // x is distance from 1.0, so 1 +/- roundish(x) gives a good location for a grid line. | |
| func roundish(x float64) (float64, int) { | |
| if !(x > 0) { // catch NaN also. | |
| panic(fmt.Sprintf("Roundish(%.9g <= 0)", x)) | |
| } | |
| if x >= 1 { | |
| return math.Trunc(x), 0 | |
| } | |
| if x >= 0.5 { | |
| return 0.5, 1 | |
| } | |
| if x >= 0.25 { | |
| return 0.25, 2 | |
| } | |
| if x >= 0.2 { | |
| return 0.2, 1 | |
| } | |
| if x >= 0.1 { | |
| return 0.1, 1 | |
| } | |
| x, n := roundish(x * 10) | |
| return x / 10, n + 1 | |
| } | |
| func reverseTicks(ticks []plot.Tick) []plot.Tick { | |
| l := len(ticks) | |
| for i := 0; i < l/2; i++ { | |
| ticks[i], ticks[l-i-1] = ticks[l-i-1], ticks[i] | |
| } | |
| return ticks | |
| } | |
| func ratioLines(low, high, min, max float64) Lines { | |
| if high <= 1 { | |
| if low == 1 { | |
| // TODO this is a degenerate case | |
| return Lines{ticks: []plot.Tick{one}} | |
| } | |
| step, k := roundish(1 - low) | |
| var ticks []plot.Tick | |
| for t := 1.0; t > min; t -= step { | |
| ticks = append(ticks, tick(t, k)) | |
| } | |
| return Lines{ticks: reverseTicks(ticks)} | |
| } else if low >= 1 { | |
| step, k := roundish(high - 1) | |
| k++ // for 1.frac | |
| var ticks []plot.Tick | |
| for t := 1.0; t < max; t += step { | |
| ticks = append(ticks, tick(t, k)) | |
| } | |
| return Lines{ticks: ticks} | |
| } | |
| rmin, kmin := roundish(1 - low) | |
| rmax, k := roundish(high - 1) | |
| if rmax < rmin { | |
| rmax = rmin | |
| k = kmin | |
| } | |
| k++ // for 1.frac | |
| step := rmax | |
| var ticks []plot.Tick | |
| for t := 1.0; t > min; t -= step { | |
| ticks = append(ticks, tick(t, k)) | |
| } | |
| ticks = reverseTicks(ticks) | |
| for t := 1.0 + step; t < max; t += step { | |
| ticks = append(ticks, tick(t, k)) | |
| } | |
| return Lines{ticks: ticks} | |
| } | |
| func tick(x float64, k int) plot.Tick { | |
| return plot.Tick{Value: x, Label: fmt.Sprintf("%.[2]*[1]g", x, k)} | |
| } | |
| var one = plot.Tick{ | |
| Value: 1.0, Label: "1.0", | |
| } | |
| func (u Lines) Ticks(min, max float64) []plot.Tick { | |
| return u.ticks | |
| } |