| // 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}) |
| } |
| |
| // 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 |
| } |