├── .gitignore ├── go.mod ├── README.md ├── go.sum ├── internal └── plot │ ├── config.go │ ├── aes.go │ ├── scale.go │ ├── transform.go │ ├── plot.go │ └── gnuplot.go ├── LICENSE └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | /benchplot 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/aclements/benchplot 2 | 3 | go 1.22.0 4 | 5 | require golang.org/x/perf v0.0.0-20240208143119-b26761745961 6 | 7 | require github.com/aclements/go-moremath v0.0.0-20210112150236-f10218a38794 // indirect 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | `benchplot` is a tool for producing plots from Go benchmark results. 2 | It's a companion to 3 | [`benchstat`](https://pkg.go.dev/golang.org/x/perf/cmd/benchstat). 4 | 5 | This tool is very much a work in progress, but it's already fairly 6 | capable. 7 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aclements/go-moremath v0.0.0-20210112150236-f10218a38794 h1:xlwdaKcTNVW4PtpQb8aKA4Pjy0CdJHEqvFbAnvR5m2g= 2 | github.com/aclements/go-moremath v0.0.0-20210112150236-f10218a38794/go.mod h1:7e+I0LQFUI9AXWxOfsQROs9xPhoJtbsyWcjJqDd4KPY= 3 | golang.org/x/perf v0.0.0-20240208143119-b26761745961 h1:/xigTF9n9L6Plv6RlldYrF6QUT4bDsLrMS9LjEioIB0= 4 | golang.org/x/perf v0.0.0-20240208143119-b26761745961/go.mod h1:gmN7ENXCRBmyb9TdgXLM3ajXxKjIEnsNQovlT6Jv4Lg= 5 | -------------------------------------------------------------------------------- /internal/plot/config.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package plot 6 | 7 | import "golang.org/x/perf/benchproc" 8 | 9 | type Config struct { 10 | aes aesMap[projection] 11 | 12 | logScale aesMap[int] 13 | } 14 | 15 | func NewConfig() *Config { 16 | return &Config{} 17 | } 18 | 19 | // SetIV maps independent variable iv to aesthetic aes. 20 | func (c *Config) SetIV(aes Aes, iv *benchproc.Projection) { 21 | fields := iv.Fields() 22 | var ivField *benchproc.Field 23 | if len(fields) == 1 && !fields[0].IsTuple { 24 | ivField = fields[0] 25 | } 26 | var unitField *benchproc.Field 27 | for _, field := range fields { 28 | if field.Name == ".unit" { 29 | unitField = field 30 | } 31 | } 32 | c.aes.Set(aes, projection{iv: iv, ivField: ivField, unitField: unitField}) 33 | } 34 | 35 | // SetDV maps the dependent variable to aesthetic aes. 36 | func (c *Config) SetDV(aes Aes) { 37 | c.aes.Set(aes, projection{dv: true}) 38 | } 39 | 40 | // SetLogScale sets the aesthetic dimension aes to use a log scale in the given 41 | // base. Base 0 represents a linear scale. 42 | func (c *Config) SetLogScale(aes Aes, base int) { 43 | c.logScale.Set(aes, base) 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 The Go Authors. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are 5 | met: 6 | 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above 10 | copyright notice, this list of conditions and the following disclaimer 11 | in the documentation and/or other materials provided with the 12 | distribution. 13 | * Neither the name of Google Inc. nor the names of its 14 | contributors may be used to endorse or promote products derived from 15 | this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | 29 | -------------------------------------------------------------------------------- /internal/plot/aes.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package plot 6 | 7 | import ( 8 | "fmt" 9 | "strings" 10 | "sync" 11 | ) 12 | 13 | // Aes is the name of an aesthetic. 14 | type Aes int 15 | 16 | const ( 17 | AesX Aes = iota 18 | AesY 19 | AesColor 20 | AesPoint // Point shape 21 | AesRow // Facet row 22 | AesCol // Facet column 23 | 24 | aesMax 25 | 26 | aesNone = Aes(-1) 27 | ) 28 | 29 | // Name returns a short name for aesthetic a, such as "x". 30 | func (a Aes) Name() string { 31 | switch a { 32 | case AesX: 33 | return "x" 34 | case AesY: 35 | return "y" 36 | case AesColor: 37 | return "color" 38 | case AesPoint: 39 | return "point" 40 | case AesRow: 41 | return "row" 42 | case AesCol: 43 | return "col" 44 | } 45 | return fmt.Sprintf("Aes(%d)", a) 46 | } 47 | 48 | var nameToAes = sync.OnceValue(func() map[string]Aes { 49 | m := make(map[string]Aes) 50 | for i := Aes(0); i < aesMax; i++ { 51 | m[i.Name()] = i 52 | } 53 | return m 54 | }) 55 | 56 | // AesFromName is the inverse of [Aes.Name]. 57 | func AesFromName(name string) (Aes, bool) { 58 | aes, ok := nameToAes()[name] 59 | return aes, ok 60 | } 61 | 62 | // aesMap is an efficient map from Aes to T. 63 | type aesMap[T any] struct { 64 | aes [aesMax]T 65 | } 66 | 67 | func (m *aesMap[T]) Set(aes Aes, val T) { 68 | m.aes[aes] = val 69 | } 70 | 71 | func (m *aesMap[T]) Get(aes Aes) T { 72 | return m.aes[aes] 73 | } 74 | 75 | func (m *aesMap[T]) Copy() aesMap[T] { 76 | return *m 77 | } 78 | 79 | func (m *aesMap[T]) String() string { 80 | var buf strings.Builder 81 | buf.WriteByte('{') 82 | for aes := range aesMax { 83 | if aes > 0 { 84 | buf.WriteString(", ") 85 | } 86 | buf.WriteString(aes.Name()) 87 | buf.WriteByte(':') 88 | fmt.Fprint(&buf, m.Get(aes)) 89 | } 90 | buf.WriteByte('}') 91 | return buf.String() 92 | } 93 | 94 | // transformAesMap sets the aesthetics in dst by transforming each value in src 95 | // using transform. 96 | func transformAesMap[S, D any](src *aesMap[S], dst *aesMap[D], transform func(S) D) { 97 | for i := range src.aes { 98 | dst.aes[i] = transform(src.aes[Aes(i)]) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /internal/plot/scale.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package plot 6 | 7 | import ( 8 | "fmt" 9 | "strings" 10 | 11 | "golang.org/x/perf/benchunit" 12 | ) 13 | 14 | func pointsKinds(pts []point, aes Aes) valueKinds { 15 | kinds := kindAll 16 | for _, pt := range pts { 17 | kinds &= pt.Get(aes).kinds 18 | } 19 | return kinds 20 | } 21 | 22 | func valuesKinds(values []value) valueKinds { 23 | kinds := kindAll 24 | for _, value := range values { 25 | kinds &= value.kinds 26 | } 27 | return kinds 28 | } 29 | 30 | // pointsUnits returns the distinct units in pts. It returns nil if pts is empty 31 | // or there is no unit field. 32 | func (p *Plot) pointsUnits(pts []point) []string { 33 | if len(pts) == 0 || p.unitField == nil { 34 | return nil 35 | } 36 | 37 | unitNames := make([]string, 0, 1) 38 | var unitSet map[string]struct{} // Lazily initialized. 39 | for i, pt := range pts { 40 | n := pt.Get(p.unitAes).key.Get(p.unitField) 41 | if i == 0 { 42 | unitNames = append(unitNames, n) 43 | continue 44 | } 45 | if n == unitNames[0] { 46 | continue 47 | } 48 | // Tricky case: there are multiple units 49 | if unitSet == nil { 50 | // Lazily initialize the set. 51 | unitSet = make(map[string]struct{}) 52 | unitSet[unitNames[0]] = struct{}{} 53 | } 54 | if _, ok := unitSet[n]; ok { 55 | continue 56 | } 57 | unitSet[n] = struct{}{} 58 | unitNames = append(unitNames, n) 59 | } 60 | 61 | return unitNames 62 | } 63 | 64 | // ordScale returns an ordinal scale from aes to [0, bound), as well as the 65 | // reverse scale. 66 | func ordScale(pts []point, aes Aes) (scale func(point) int, rev func(int) value, bound int) { 67 | // Collect all unique values. 68 | vals := make(map[value]struct{}) 69 | for _, pt := range pts { 70 | vals[pt.Get(aes)] = struct{}{} 71 | } 72 | // Create a mapping index. 73 | sorted := sortedValues(vals) 74 | ord := make(map[value]int) 75 | for i, k := range sorted { 76 | ord[k] = i 77 | } 78 | return func(pt point) int { 79 | if idx, ok := ord[pt.Get(aes)]; ok { 80 | return idx 81 | } 82 | panic("value has unmapped key") 83 | }, func(index int) value { 84 | return sorted[index] 85 | }, len(ord) 86 | } 87 | 88 | func (p *Plot) continuousScale(pts []point, aes Aes, rescale bool) (scale func(float64) float64, lo, hi float64, label string, err error) { 89 | if pointsKinds(pts, aes)&kindContinuous == 0 { 90 | err = fmt.Errorf("%s data must be numeric", aes.Name()) 91 | return 92 | } 93 | 94 | // Find bounds. 95 | for i, pt := range pts { 96 | val := pt.Get(aes).val 97 | if i == 0 { 98 | lo, hi = val, val 99 | } else { 100 | lo, hi = min(lo, val), max(hi, val) 101 | } 102 | } 103 | 104 | // We only apply scaling to the DV. 105 | projection := p.aes.Get(aes) 106 | if len(pts) == 0 || !projection.dv { 107 | // No-op scale. 108 | label = projection.String() 109 | scale = func(v float64) float64 { 110 | return v 111 | } 112 | return 113 | } 114 | 115 | // Get units. 116 | // 117 | // We can wind up with multiple units if, say, -color is configured to 118 | // .unit. In that case, we combine all of the units. 119 | unitNames := p.pointsUnits(pts) 120 | 121 | prefix := "" 122 | if rescale { 123 | // Get unit class. 124 | cls := benchunit.Decimal 125 | if len(unitNames) == 1 { 126 | cls = benchunit.ClassOf(unitNames[0]) 127 | } 128 | 129 | // Construct scaler. We pass only the highest value. Otherwise this will try 130 | // to pick a scale that keeps precision for the *smallest* value, which 131 | // isn't what you want on an axis. 132 | scaler := benchunit.CommonScale([]float64{hi}, cls) 133 | scale = func(v float64) float64 { 134 | return v / scaler.Factor 135 | } 136 | prefix = scaler.Prefix 137 | } else { 138 | // The caller has requested that we not rescale values (probably because 139 | // the plotter it's talking to has that ability). 140 | scale = func(v float64) float64 { 141 | return v 142 | } 143 | } 144 | 145 | // Construct label. 146 | // 147 | // TODO: This could result in some weird looking units. Ideally benchunit 148 | // would have something that could say "this unit's base quantity is time 149 | // (and thus base unit is seconds)", allowing us to scale and represent it, 150 | // though then we'd probably need to compute our own tick marks. 151 | labels := make([]string, 0, 1) 152 | for _, n := range unitNames { 153 | labels = append(labels, prefix+n) 154 | } 155 | label = strings.Join(labels, ", ") 156 | 157 | return 158 | } 159 | -------------------------------------------------------------------------------- /internal/plot/transform.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package plot 6 | 7 | import ( 8 | "fmt" 9 | "slices" 10 | 11 | "golang.org/x/perf/benchmath" 12 | ) 13 | 14 | func pointsToSample(pts []point, aes Aes) *benchmath.Sample { 15 | ys := make([]float64, 0, 16) 16 | for _, pt := range pts { 17 | val := pt.Get(aes) 18 | if val.kinds&kindContinuous == 0 { 19 | panic("non-continuous " + aes.Name()) 20 | } 21 | ys = append(ys, val.val) 22 | } 23 | return benchmath.NewSample(ys, &benchmath.DefaultThresholds) 24 | } 25 | 26 | // transformSummarize groups points that differ only in aes and produces a 27 | // single point for each group where aes is set to a summary of the group. 28 | // 29 | // aes must have kind kindContinuous. 30 | func transformSummarize(pts []point, aes Aes, confidence float64) ([]point, error) { 31 | kinds := pointsKinds(pts, aes) 32 | if kinds&kindSummary != 0 { 33 | // Nothing to do if it's already summaries. 34 | return pts, nil 35 | } 36 | if kinds&kindContinuous == 0 { 37 | return nil, fmt.Errorf("transformSummarize: %s data must be numeric", aes.Name()) 38 | } 39 | 40 | groups, keys := groupBy(pts, func(pt point) point { 41 | pt.Set(aes, value{}) 42 | return pt 43 | }) 44 | 45 | // Compute summary for each group. We put this in a slice to avoid 46 | // allocating each Summary separately. 47 | summaries := make([]benchmath.Summary, len(keys)) 48 | for i, k := range keys { 49 | sample := pointsToSample(groups[k], aes) 50 | summaries[i] = benchmath.AssumeNothing.Summary(sample, confidence) 51 | } 52 | 53 | // Construct new points. 54 | out := make([]point, len(keys)) 55 | for i, k := range keys { 56 | pt := groups[k][0] 57 | // Keep it as a ratio if the input is. 58 | kinds := kindContinuous | kindSummary | (kinds & kindRatio) 59 | summary := &summaries[i] 60 | v := value{kinds: kinds, val: summary.Center, summary: summary} 61 | pt.Set(aes, v) 62 | out[i] = pt 63 | } 64 | 65 | return out, nil 66 | } 67 | 68 | func (p *Plot) TransformCompare() error { 69 | // TODO: Account for AesPoint here, too. 70 | // 71 | // TODO: It feels weird to pass AesColor here. Should this be up to what 72 | // type of plot we're creating? Really this is its own "series" aesthetic 73 | // and things like color and point shape just need to be preserved, but it 74 | // is annoying to have to switch how you specify series just to add this 75 | // transform. 76 | pts, err := transformCompare(p.points, AesColor, p.dvAes) 77 | if err != nil { 78 | return err 79 | } 80 | p.points = pts 81 | return nil 82 | } 83 | 84 | // transformCompare groups points that differ only in aesCompare and aesRatio 85 | // and for each distinct value of aesCompare, treats the first value as a 86 | // baseline and normalizes the aesRatio of all other values of aesCompare 87 | // against that baseline. 88 | // 89 | // TODO: Right now, this collapses each group down to a median and produces only 90 | // continuous values for aes. It really ought to compute summary values. 91 | func transformCompare(pts []point, aesCompare, aesRatio Aes) ([]point, error) { 92 | if len(pts) == 0 { 93 | return nil, nil 94 | } 95 | 96 | if pointsKinds(pts, aesRatio)&kindContinuous == 0 { 97 | return nil, fmt.Errorf("transformCompare: %s data must be numeric", aesRatio.Name()) 98 | } 99 | 100 | // We want to walk through things in order of aesCompare. Sort it up-front 101 | // and the grouping operations will keep it sorted. 102 | slices.SortFunc(pts, func(a, b point) int { 103 | return a.Get(aesCompare).compare(b.Get(aesCompare)) 104 | }) 105 | // Everything shares the same comparison baseline. 106 | cmpBase := pts[0].Get(aesCompare) 107 | 108 | groups, keys := groupBy(pts, func(pt point) point { 109 | pt.Set(aesCompare, value{}) 110 | pt.Set(aesRatio, value{}) 111 | return pt 112 | }) 113 | 114 | median := func(pts []point) float64 { 115 | // TODO: This does a ton of wasted computation. 116 | return benchmath.AssumeNothing.Summary(pointsToSample(pts, aesRatio), 1).Center 117 | } 118 | 119 | var out []point 120 | for _, k := range keys { 121 | group := groups[k] 122 | 123 | // If this group doesn't have a baseline, skip it entirely. 124 | if group[0].Get(aesCompare) != cmpBase { 125 | continue 126 | } 127 | 128 | // Group by value of aesCompare. 129 | cmpGroups, cmpKeys := groupBy(group, func(pt point) value { 130 | return pt.Get(aesCompare) 131 | }) 132 | 133 | // Create a point for each value of aesCompare, normalized to the first. 134 | baseline := median(cmpGroups[cmpBase]) 135 | for _, ck := range cmpKeys[1:] { 136 | ratio := median(cmpGroups[ck]) / baseline 137 | p0 := cmpGroups[ck][0] 138 | ck.kinds |= kindRatio 139 | ck.denom = cmpBase.key 140 | p0.Set(aesCompare, ck) 141 | p0.Set(aesRatio, value{kinds: kindContinuous | kindRatio, val: ratio}) 142 | out = append(out, p0) 143 | } 144 | } 145 | 146 | return out, nil 147 | } 148 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "flag" 9 | "fmt" 10 | "io" 11 | "os" 12 | "slices" 13 | "strconv" 14 | "strings" 15 | 16 | "github.com/aclements/benchplot/internal/plot" 17 | "golang.org/x/perf/benchfmt" 18 | "golang.org/x/perf/benchproc" 19 | ) 20 | 21 | func main() { 22 | if err := benchplot(os.Stdout, os.Stderr, os.Args[1:]); err != nil { 23 | fmt.Fprintf(os.Stderr, "%s\n", err) 24 | os.Exit(1) 25 | } 26 | } 27 | 28 | type aesFlag struct { 29 | aes plot.Aes 30 | def string 31 | doc string 32 | } 33 | 34 | var aesFlags = []aesFlag{ 35 | {plot.AesX, ".fullname", "map values of `projection` to the X axis"}, 36 | {plot.AesY, ".value", "map values of `projection` to the Y axis"}, 37 | {plot.AesColor, ".residue", "map values of `projection` to color"}, 38 | {plot.AesPoint, "", "map values of `projection` to point shape"}, 39 | {plot.AesRow, ".unit", "map values of `projection` to facet rows"}, 40 | {plot.AesCol, "", "map values of `projection` to facet columns"}, 41 | } 42 | 43 | type transformOpt struct { 44 | doc string 45 | do func(p *plot.Plot) error 46 | } 47 | 48 | var transformOpts = map[string]transformOpt{ 49 | "compare": {"normalize each value against the first value at the same X", 50 | (*plot.Plot).TransformCompare}, 51 | } 52 | 53 | func benchplot(w, wErr io.Writer, args []string) error { 54 | flags := flag.NewFlagSet("", flag.ExitOnError) 55 | flags.SetOutput(wErr) 56 | 57 | // We break the flags into a few subsets for help printing. 58 | mainFlagSet := flag.NewFlagSet("", 0) 59 | mainFlagSet.SetOutput(wErr) 60 | aesFlagSet := flag.NewFlagSet("", 0) 61 | aesFlagSet.SetOutput(wErr) 62 | 63 | flags.Usage = func() { 64 | fmt.Fprintf(wErr, `Usage: benchplot [flags] inputs... 65 | `) 66 | mainFlagSet.PrintDefaults() 67 | 68 | // Print aesthetic flags in natural order. 69 | fmt.Fprintf(wErr, "\nAesthetic flags:\n") 70 | for _, f := range aesFlags { 71 | fset := flag.NewFlagSet("", 0) 72 | fset.SetOutput(wErr) 73 | fset.String(f.aes.Name(), f.def, f.doc) 74 | fset.PrintDefaults() 75 | } 76 | fmt.Fprintf(wErr, ` 77 | For the syntax of projections, see 78 | 79 | https://pkg.go.dev/golang.org/x/perf/benchproc/syntax 80 | 81 | In addition, any projection may be one of the following: 82 | 83 | .unit The unit of each benchmark-reported metric 84 | .value The value of the metric corresponding to .unit 85 | .residue All fields that were not in some other projection 86 | `) 87 | 88 | // Print transforms. 89 | fmt.Fprintf(wErr, "\nTransformations:\n") 90 | var names []string 91 | for name := range transformOpts { 92 | names = append(names, name) 93 | } 94 | slices.Sort(names) 95 | for _, name := range names { 96 | fmt.Fprintf(wErr, " %s\n \t%s\n", name, transformOpts[name].doc) 97 | } 98 | } 99 | 100 | // Register aesthetic flags. 101 | type aesFlagReg struct { 102 | aesFlag 103 | 104 | flagString *string 105 | 106 | dv bool 107 | proj *benchproc.Projection 108 | } 109 | var aesFlagRegs = make([]aesFlagReg, 0, len(aesFlags)) 110 | for _, f := range aesFlags { 111 | aesFlagRegs = append(aesFlagRegs, 112 | aesFlagReg{ 113 | aesFlag: f, 114 | flagString: aesFlagSet.String(f.aes.Name(), f.def, f.doc), 115 | }) 116 | } 117 | 118 | // Register main flags. 119 | flagIgnore := mainFlagSet.String("ignore", "", "ignore variations in `keys`") 120 | flagFilter := mainFlagSet.String("filter", "*", "use only benchmarks matching benchfilter `query`") 121 | // This is a convenience filter, since if you want to filter on anything, 122 | // it's usually this. 123 | flagUnits := mainFlagSet.String("unit", "", "comma-separated list of `units` to show") 124 | flagLogScale := mainFlagSet.String("log-scale", "", "comma-separated `list` of options to plot on a log scale\nUse name:base to set a log base other than 10") 125 | flagTransform := mainFlagSet.String("transform", "", "comma-separated `list` of data transformations") 126 | 127 | // Merge flag sets. 128 | mergeFlags := func(dst, src *flag.FlagSet) { 129 | src.VisitAll(func(f *flag.Flag) { 130 | dst.Var(f.Value, f.Name, f.Usage) 131 | }) 132 | } 133 | mergeFlags(flags, mainFlagSet) 134 | mergeFlags(flags, aesFlagSet) 135 | 136 | // Parse flags. Finally! 137 | flags.Parse(args) 138 | if flags.NArg() == 0 { 139 | flags.Usage() 140 | os.Exit(2) 141 | } 142 | 143 | config := plot.NewConfig() 144 | 145 | // Parse filter options. 146 | filter, err := benchproc.NewFilter(*flagFilter) 147 | if err != nil { 148 | return fmt.Errorf("parsing -filter: %s", err) 149 | } 150 | var keepUnits map[string]bool 151 | if *flagUnits != "" { 152 | keepUnits = make(map[string]bool) 153 | for _, unit := range strings.Split(*flagUnits, ",") { 154 | keepUnits[unit] = true 155 | } 156 | } 157 | 158 | // Parse projection options. 159 | var parser benchproc.ProjectionParser 160 | var parseResidue []*aesFlagReg 161 | for i := range aesFlagRegs { 162 | f := &aesFlagRegs[i] 163 | switch *f.flagString { 164 | case ".unit": 165 | // TODO: Ideally you should be able to combine .unit with other 166 | // projection bits. I remember specifically not wanting this for 167 | // benchstat, so maybe this has to be an option to the parser. 168 | proj, _, _ := parser.ParseWithUnit("", filter) 169 | f.proj = proj 170 | case ".value": 171 | f.dv = true 172 | case ".residue": 173 | parseResidue = append(parseResidue, f) 174 | default: 175 | proj, err := parser.Parse(*f.flagString, filter) 176 | if err != nil { 177 | return fmt.Errorf("parsing -%s: %s", f.aes.Name(), err) 178 | } 179 | f.proj = proj 180 | } 181 | } 182 | 183 | // Process projection residue. 184 | _, err = parser.Parse(*flagIgnore, filter) 185 | if err != nil { 186 | return fmt.Errorf("parsing -ignore: %s", err) 187 | } 188 | residue := parser.Residue() 189 | if len(parseResidue) > 0 { 190 | // If any of the projections are the residue, set them and 191 | // clear the explicit residue. 192 | // 193 | // TODO: Otherwise, report residue mismatches. 194 | for _, f := range parseResidue { 195 | f.proj = residue 196 | } 197 | residue = nil 198 | } 199 | 200 | // Bind projections to aesthetics. 201 | for _, f := range aesFlagRegs { 202 | if f.dv { 203 | config.SetDV(f.aes) 204 | } else { 205 | config.SetIV(f.aes, f.proj) 206 | } 207 | } 208 | 209 | // Parse log-scale option. 210 | if *flagLogScale != "" { 211 | for _, opt := range strings.Split(*flagLogScale, ",") { 212 | opt, baseStr, hasBase := strings.Cut(opt, ":") 213 | aes, ok := plot.AesFromName(opt) 214 | if !ok { 215 | return fmt.Errorf("unknown option %s in -log-scale=%s", opt, *flagLogScale) 216 | } 217 | base := 10 218 | if hasBase { 219 | base2, err := strconv.ParseInt(baseStr, 10, 0) 220 | if err != nil { 221 | return fmt.Errorf("bad base %s in -log-scale=%s: %w", baseStr, *flagLogScale, err) 222 | } 223 | base = int(base2) 224 | } 225 | config.SetLogScale(aes, base) 226 | } 227 | 228 | } 229 | 230 | // Parse transforms. 231 | var transforms []func(p *plot.Plot) error 232 | if *flagTransform != "" { 233 | for _, opt := range strings.Split(*flagTransform, ",") { 234 | t, ok := transformOpts[opt] 235 | if !ok { 236 | return fmt.Errorf("unknown transform %s", opt) 237 | } 238 | transforms = append(transforms, t.do) 239 | } 240 | } 241 | 242 | // Read inputs. 243 | var errors []errorAt 244 | var nParsed, nFiltered, nUnitFiltered int 245 | pl, err := plot.NewPlot(config) 246 | if err != nil { 247 | return err 248 | } 249 | files := benchfmt.Files{Paths: flags.Args(), AllowStdin: true, AllowLabels: true} 250 | for files.Scan() { 251 | switch rec := files.Result(); rec := rec.(type) { 252 | case *benchfmt.SyntaxError: 253 | // Non-fatal result parse error. Warn 254 | // but keep going. 255 | fmt.Fprintln(wErr, rec) 256 | case *benchfmt.Result: 257 | nParsed++ 258 | if ok, err := filter.Apply(rec); !ok { 259 | nFiltered++ 260 | if err != nil { 261 | // Print the reason we rejected this result. 262 | fmt.Fprintln(wErr, err) 263 | } 264 | continue 265 | } 266 | if keepUnits != nil { 267 | j := 0 268 | for _, val := range rec.Values { 269 | if keepUnits[val.Unit] || (val.OrigUnit != "" && keepUnits[val.OrigUnit]) { 270 | rec.Values[j] = val 271 | j++ 272 | } 273 | } 274 | rec.Values = rec.Values[:j] 275 | if j == 0 { 276 | nUnitFiltered++ 277 | continue 278 | } 279 | } 280 | 281 | pl.Add(rec) 282 | } 283 | } 284 | if err := files.Err(); err != nil { 285 | return err 286 | } 287 | pl.SetUnits(files.Units()) 288 | if nParsed == 0 { 289 | return fmt.Errorf("no data") 290 | } else if nUnitFiltered == nParsed { 291 | return fmt.Errorf("no data has units %s", *flagUnits) 292 | } else if nUnitFiltered+nFiltered == nParsed { 293 | return fmt.Errorf("all data filtered") 294 | } 295 | if len(errors) > 0 { 296 | // No need to sort right now because they're already in order. 297 | return errorsAt(errors) 298 | } 299 | if nFiltered > 0 || nUnitFiltered > 0 { 300 | fmt.Fprintf(wErr, "%d records did not match -filter, %d records did not match -unit\n", nFiltered, nUnitFiltered) 301 | } 302 | 303 | // Apply transforms. 304 | for _, transform := range transforms { 305 | if err := transform(pl); err != nil { 306 | return err 307 | } 308 | } 309 | 310 | //code, err := plot.GnuplotCode() 311 | f, err := os.Create("benchplot.png") 312 | if err != nil { 313 | return err 314 | } 315 | defer f.Close() 316 | err = pl.Gnuplot("png", f) 317 | if err != nil { 318 | return err 319 | } 320 | //fmt.Fprint(w, code) 321 | return nil 322 | } 323 | 324 | type errorAt struct { 325 | file string 326 | line int 327 | err error 328 | } 329 | 330 | func (e errorAt) Error() string { 331 | return fmt.Sprintf("%s:%d: %s", e.file, e.line, e.err.Error()) 332 | } 333 | 334 | type errorsAt []errorAt 335 | 336 | func (e errorsAt) Error() string { 337 | var b strings.Builder 338 | for i, err := range e { 339 | if i == 10 { 340 | b.WriteString("more errors...") 341 | break 342 | } 343 | if i > 0 { 344 | b.WriteByte('\n') 345 | } 346 | b.WriteString(err.Error()) 347 | } 348 | return b.String() 349 | } 350 | -------------------------------------------------------------------------------- /internal/plot/plot.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package plot 6 | 7 | import ( 8 | "cmp" 9 | "fmt" 10 | "slices" 11 | "strconv" 12 | "strings" 13 | 14 | "golang.org/x/perf/benchfmt" 15 | "golang.org/x/perf/benchmath" 16 | "golang.org/x/perf/benchproc" 17 | ) 18 | 19 | // TODO: Do something with the residue. Probably complain if two points we're 20 | // averaging have different residues, much like benchstat does. 21 | 22 | // TODO: This is currently incapable of plotting one unit against another. E.g., 23 | // I was trying to make a plot with one measurement on the X axis (in this case, 24 | // "flushes") and ns/op on the Y axis, to see how they related. 25 | 26 | // TODO: Rearrange all of this in terms of data frames/column stores? A given 27 | // aesthetic would map to a type-consistent vector and a "point"/tuple would be 28 | // an index into a frame (or maybe an identifier that could be followed through 29 | // sorting, etc?). This has some nice advantages: the types of columns are very 30 | // clear, you can do whole-column coercions by replacing the column, and 31 | // grouping operations can make it very clear what aesthetics have been grouped 32 | // away and what still varies. It may be that as part of this we want to further 33 | // decouple the "plot benchmark results" from "plot data", though the stats and 34 | // units may be annoying to abstract away. This may give us a good "intrinsic" 35 | // way of serializing a plot that can be read back and rendered, perhaps across 36 | // a frontend/backend split. 37 | 38 | type Plot struct { 39 | // aes is the projection for each aesthetic dimension. 40 | aes aesMap[projection] 41 | 42 | // unitAes is the aesthetic the unit (.unit) is bound to, if any. 43 | unitAes Aes 44 | // unitField is the field of unitAes's projection the unit is bound to. 45 | unitField *benchproc.Field 46 | // dvAes is the aesthetic the dependent variable (.value) is bound to, if any. 47 | dvAes Aes 48 | 49 | // logScale is the log base for each aesthetic, or 0 for linear. 50 | logScale aesMap[int] 51 | 52 | units benchfmt.UnitMetadataMap 53 | 54 | points []point 55 | } 56 | 57 | // A projection describes how to map from a [benchfmt.Result] to a value. The 58 | // zero value maps all Results to the zero value. 59 | type projection struct { 60 | iv *benchproc.Projection 61 | ivField *benchproc.Field // set if iv has exactly one field 62 | unitField *benchproc.Field // set if iv has a .unit field 63 | 64 | dv bool 65 | } 66 | 67 | type value struct { 68 | kinds valueKinds 69 | key benchproc.Key // if kinds & kindDiscrete 70 | val float64 // if kinds & kindContinuous 71 | 72 | summary *benchmath.Summary // if kinds & kindSummary 73 | denom benchproc.Key // if kindRatio AND kindDiscrete 74 | } 75 | 76 | type valueKinds uint8 77 | 78 | const ( 79 | kindDiscrete valueKinds = 1 << iota 80 | kindContinuous 81 | kindSummary // Implies kindContinuous 82 | kindRatio // Implies kindContinuous OR kindDiscrete 83 | 84 | kindMax 85 | 86 | kindAll = kindMax - 1 87 | ) 88 | 89 | type point struct { 90 | aesMap[value] 91 | } 92 | 93 | func NewPlot(c *Config) (*Plot, error) { 94 | // Find unit and DV. 95 | unitAes, dvAes := aesNone, aesNone 96 | var unitField *benchproc.Field 97 | for aes := range aesMax { 98 | proj := c.aes.Get(aes) 99 | if proj.unitField != nil { 100 | if unitAes != aesNone { 101 | return nil, fmt.Errorf("at most one dimension may show .unit") 102 | } 103 | unitAes, unitField = aes, proj.unitField 104 | } 105 | if proj.dv { 106 | if dvAes != aesNone { 107 | return nil, fmt.Errorf("at most one dimension may show .value") 108 | } 109 | dvAes = aes 110 | } 111 | } 112 | // If we have a unit field, then we also need a DV. 113 | if unitAes != aesNone && dvAes == aesNone { 114 | return nil, fmt.Errorf(".unit is mapped to the %s dimension, but no dimension shows .value", unitAes.Name()) 115 | } 116 | if unitAes == aesNone && dvAes != aesNone { 117 | return nil, fmt.Errorf(".value is mapped to the %s dimension, but no dimension shows .unit", dvAes.Name()) 118 | } 119 | 120 | return &Plot{ 121 | aes: c.aes.Copy(), 122 | unitAes: unitAes, 123 | unitField: unitField, 124 | dvAes: dvAes, 125 | logScale: c.logScale, 126 | }, nil 127 | } 128 | 129 | func (p projection) project(r *benchfmt.Result) []value { 130 | if p.dv { 131 | panic("cannot project DV") 132 | } 133 | if p.iv == nil { 134 | return []value{{kinds: kindDiscrete}} 135 | } 136 | 137 | var values []value 138 | if p.unitField != nil { 139 | for _, key := range p.iv.ProjectValues(r) { 140 | values = append(values, value{kinds: kindDiscrete, key: key}) 141 | } 142 | } else { 143 | key := p.iv.Project(r) 144 | values = append(values, value{kinds: kindDiscrete, key: key}) 145 | } 146 | 147 | // Try to parse continuous values, too. 148 | if p.ivField != nil { 149 | for i, val := range values { 150 | s := val.key.Get(p.ivField) 151 | val, err := strconv.ParseFloat(s, 64) 152 | if err == nil { 153 | values[i].kinds |= kindContinuous 154 | values[i].val = val 155 | } 156 | } 157 | } 158 | 159 | return values 160 | } 161 | 162 | func (p projection) String() string { 163 | if p.dv { 164 | return ".value" 165 | } 166 | if p.iv != nil { 167 | var out strings.Builder 168 | for i, field := range p.iv.FlattenedFields() { 169 | if i > 0 { 170 | out.WriteByte(',') 171 | } 172 | out.WriteString(field.String()) 173 | } 174 | return out.String() 175 | } 176 | return "" 177 | } 178 | 179 | func (v value) String() string { 180 | if v.kinds&kindDiscrete != 0 { 181 | if v.kinds&kindRatio != 0 { 182 | return v.key.String() + " vs " + v.denom.String() 183 | } 184 | return v.key.String() 185 | } 186 | return fmt.Sprint(v.val) 187 | } 188 | 189 | func (v value) StringValues() string { 190 | if v.kinds&kindDiscrete != 0 { 191 | if v.kinds&kindRatio != 0 { 192 | return v.key.StringValues() + " vs " + v.denom.StringValues() 193 | } 194 | return v.key.StringValues() 195 | } 196 | return fmt.Sprint(v.val) 197 | } 198 | 199 | func (v value) compare(v2 value) int { 200 | if v.kinds&v2.kinds&kindDiscrete != 0 { 201 | if c := compareKeys(v.key, v2.key); c != 0 { 202 | return c 203 | } 204 | if v.kinds&kindRatio != 0 { 205 | return compareKeys(v.denom, v2.denom) 206 | } 207 | return 0 208 | } 209 | if v.kinds&v2.kinds&kindContinuous != 0 { 210 | return cmp.Compare(v.val, v2.val) 211 | } 212 | // Otherwise, put them in kind order. 213 | for kind := range kindMax { 214 | if v.kinds&kind != 0 && v2.kinds&kind == 0 { 215 | return -1 216 | } 217 | if v.kinds&kind == 0 && v2.kinds&kind != 0 { 218 | return 1 219 | } 220 | } 221 | // Something went very wrong. 222 | panic(fmt.Errorf("incomparable kinds %#x, %#x", v.kinds, v2.kinds)) 223 | } 224 | 225 | func (p *Plot) Label(pt point, aes Aes) string { 226 | proj := p.aes.Get(aes) 227 | if proj.dv { 228 | return pt.Get(p.unitAes).key.Get(p.unitField) 229 | } 230 | return proj.String() 231 | } 232 | 233 | func (p *Plot) Add(rec *benchfmt.Result) { 234 | var pt point 235 | var fill func(aes Aes) 236 | fill = func(aes Aes) { 237 | if aes == aesMax { 238 | // Add the point. 239 | p.points = append(p.points, point{pt.aesMap.Copy()}) 240 | return 241 | } 242 | 243 | proj := p.aes.Get(aes) 244 | if proj.dv { 245 | // We fill this in when we find the .unit projection. 246 | fill(aes + 1) 247 | return 248 | } 249 | // TODO: This is usually a single element slice, making this pretty 250 | // inefficient. 251 | vals := proj.project(rec) 252 | for _, val := range vals { 253 | if proj.unitField != nil { 254 | unitName := val.key.Get(proj.unitField) 255 | val, ok := rec.Value(unitName) 256 | if !ok { 257 | // Point is missing this unit. Just drop the point. 258 | continue 259 | } 260 | pt.aesMap.Set(p.dvAes, value{kinds: kindContinuous, val: val}) 261 | } 262 | pt.aesMap.Set(aes, val) 263 | fill(aes + 1) 264 | } 265 | } 266 | fill(0) 267 | } 268 | 269 | func (p *Plot) SetUnits(units benchfmt.UnitMetadataMap) { 270 | p.units = units 271 | } 272 | 273 | func compareKeys(a, b benchproc.Key) int { 274 | // TODO: Key should have a Compare method 275 | if a == b { 276 | return 0 277 | } 278 | if a.Less(b) { 279 | return -1 280 | } 281 | return 1 282 | } 283 | 284 | func sortedKeys(set map[benchproc.Key]struct{}) []benchproc.Key { 285 | sl := make([]benchproc.Key, 0, len(set)) 286 | for k := range set { 287 | sl = append(sl, k) 288 | } 289 | benchproc.SortKeys(sl) 290 | return sl 291 | } 292 | 293 | func sortedValues(set map[value]struct{}) []value { 294 | sl := make([]value, 0, len(set)) 295 | for v := range set { 296 | sl = append(sl, v) 297 | } 298 | 299 | slices.SortFunc(sl, func(a, b value) int { 300 | return a.compare(b) 301 | }) 302 | 303 | return sl 304 | } 305 | 306 | // sliceBy divides slice s into subslices by equal values of grouper. 307 | func sliceBy[T any, U comparable](s []T, grouper func(T) U, doGroup func(U, []T)) { 308 | if len(s) == 0 { 309 | return 310 | } 311 | 312 | start := 0 313 | startVal := grouper(s[0]) 314 | for i := 1; i < len(s); i++ { 315 | val := grouper(s[i]) 316 | if val != startVal { 317 | doGroup(startVal, s[start:i]) 318 | start, startVal = i, val 319 | } 320 | } 321 | doGroup(startVal, s[start:]) 322 | } 323 | 324 | // groupBy groups the elements of s according to the value of grouper. It 325 | // maintains the order of elements. If possible, it uses subslices of s, but it 326 | // will copy out of s if grouper returns the same value for discontinuous ranges 327 | // of s. 328 | func groupBy[T any, U comparable](s []T, grouper func(T) U) (map[U][]T, []U) { 329 | out := make(map[U][]T) 330 | if len(s) == 0 { 331 | return out, nil 332 | } 333 | 334 | var keys []U 335 | start := 0 336 | startVal := grouper(s[0]) 337 | flush := func(i int) { 338 | if old, ok := out[startVal]; !ok { 339 | // Use subslice directly. 340 | out[startVal] = s[start:i] 341 | keys = append(keys, startVal) 342 | } else { 343 | // Copy slice. 344 | out[startVal] = append(old[:len(old):len(old)], s[start:i]...) 345 | } 346 | } 347 | for i := 1; i < len(s); i++ { 348 | val := grouper(s[i]) 349 | if val != startVal { 350 | flush(i) 351 | start, startVal = i, val 352 | } 353 | } 354 | flush(len(s)) 355 | 356 | return out, keys 357 | } 358 | -------------------------------------------------------------------------------- /internal/plot/gnuplot.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package plot 6 | 7 | import ( 8 | "bytes" 9 | "cmp" 10 | "fmt" 11 | "io" 12 | "math" 13 | "os" 14 | "os/exec" 15 | "slices" 16 | "strconv" 17 | "strings" 18 | ) 19 | 20 | type gnuplotter struct { 21 | *Plot 22 | code bytes.Buffer 23 | 24 | confidence float64 25 | 26 | colorScale, pointScale func(point) int 27 | colorRev, pointRev func(int) value 28 | 29 | nColors, nPoints int 30 | } 31 | 32 | func (p *Plot) Gnuplot(term string, out io.Writer) error { 33 | pl := gnuplotter{Plot: p} 34 | if err := pl.plot(term); err != nil { 35 | return err 36 | } 37 | 38 | // TODO: Only do this if we're launching a windowed gnuplot 39 | if false { 40 | fmt.Fprintf(&pl.code, "pause mouse close\n") 41 | } 42 | code := pl.code.Bytes() 43 | 44 | switch term { 45 | case "": 46 | _, err := out.Write(code) 47 | return err 48 | case "png": 49 | cmd := exec.Command("gnuplot") 50 | stdin, err := cmd.StdinPipe() 51 | if err != nil { 52 | return fmt.Errorf("creating pipe to gnuplot: %w", err) 53 | } 54 | cmd.Stdout = out 55 | cmd.Stderr = os.Stderr 56 | if err := cmd.Start(); err != nil { 57 | return fmt.Errorf("starting gnuplot: %w", err) 58 | } 59 | defer cmd.Process.Kill() 60 | if _, err := stdin.Write(code); err != nil { 61 | return fmt.Errorf("writing to gnuplot: %w", err) 62 | } 63 | stdin.Close() 64 | if err := cmd.Wait(); err != nil { 65 | return fmt.Errorf("gnuplot failed: %w", err) 66 | } 67 | } 68 | return nil 69 | } 70 | 71 | func (p *gnuplotter) plot(term string) error { 72 | pts := p.points 73 | p.confidence = 0.95 74 | 75 | if len(pts) == 0 { 76 | return fmt.Errorf("no data") 77 | } 78 | 79 | if pointsKinds(pts, AesX)&kindContinuous == 0 { 80 | // TODO: Bar chart 81 | // 82 | // TODO: Say what type it *is* and preferably an example of what made is 83 | // non-continuous. 84 | return fmt.Errorf("non-numeric X data not supported") 85 | } 86 | if pointsKinds(pts, AesY)&kindContinuous == 0 { 87 | // TODO: Horizontal bar chart? 88 | return fmt.Errorf("non-numeric Y data not supported") 89 | } 90 | rowScale, rowRev, nRows := ordScale(pts, AesRow) 91 | colScale, colRev, nCols := ordScale(pts, AesCol) 92 | multiplot := nRows > 1 || nCols > 1 93 | p.colorScale, p.colorRev, p.nColors = ordScale(pts, AesColor) 94 | p.pointScale, p.pointRev, p.nPoints = ordScale(pts, AesPoint) 95 | 96 | switch term { 97 | case "": 98 | // Just code 99 | case "png": 100 | fmt.Fprintf(&p.code, "set terminal pngcairo size %d,%d\n", nCols*640, nRows*480) 101 | default: 102 | return fmt.Errorf("unknown output type %s", term) 103 | } 104 | 105 | // Construct multiplot row and column labels 106 | rowLabels := make([]string, nRows) 107 | colLabels := make([]string, nCols) 108 | if multiplot { 109 | // Configure multiplot 110 | fmt.Fprintf(&p.code, "set multiplot layout %d,%d columnsfirst margins char 12,char 3,char 4,char 2 spacing char 10, char 4\n", nRows, nCols) 111 | // TODO: Use a benchproc.KeyHeader to display this more nicely. It may 112 | // be really hard to center a label across a multiple graphs, though. 113 | for row := range nRows { 114 | rowLabels[row] = rowRev(row).StringValues() 115 | } 116 | for col := range nCols { 117 | colLabels[col] = colRev(col).StringValues() 118 | } 119 | } 120 | 121 | // Set log scales 122 | setLogScale := func(aes Aes, name string) { 123 | if base := p.logScale.Get(aes); base != 0 { 124 | fmt.Fprintf(&p.code, "set logscale %s %d\n", name, base) 125 | } 126 | } 127 | setLogScale(AesX, "x") 128 | setLogScale(AesY, "y") 129 | 130 | // Sort the points in the order the data must be emitted. 131 | slices.SortFunc(pts, func(a, b point) int { 132 | if c := a.Get(AesCol).compare(b.Get(AesCol)); c != 0 { 133 | return c 134 | } 135 | if c := a.Get(AesRow).compare(b.Get(AesRow)); c != 0 { 136 | return c 137 | } 138 | if c := a.Get(AesColor).compare(b.Get(AesColor)); c != 0 { 139 | return c 140 | } 141 | if c := a.Get(AesPoint).compare(b.Get(AesPoint)); c != 0 { 142 | return c 143 | } 144 | // For a line plot, X must be sorted numerically. 145 | return cmp.Compare(a.Get(AesX).val, b.Get(AesX).val) 146 | }) 147 | 148 | // Emit plots 149 | type rowCol struct{ row, col int } 150 | plots, _ := groupBy(pts, func(pt point) rowCol { 151 | return rowCol{rowScale(pt), colScale(pt)} 152 | }) 153 | for col := range nCols { 154 | for row := range nRows { 155 | pts := plots[rowCol{row, col}] 156 | if multiplot && col == 0 { 157 | // Label this row. 158 | fmt.Fprintf(&p.code, "set label 1 %s at char 2, graph 0.5 center rotate by 90\n", gpString(rowLabels[row])) 159 | } 160 | if multiplot && row == 0 { 161 | // Label this column. 162 | fmt.Fprintf(&p.code, "set title %s\n", gpString(colLabels[col])) 163 | } 164 | p.onePlot(pts, rowLabels[row], colLabels[col]) 165 | fmt.Fprintf(&p.code, "unset label 1\n") 166 | fmt.Fprintf(&p.code, "unset title\n") 167 | } 168 | } 169 | 170 | if multiplot { 171 | fmt.Fprintf(&p.code, "unset multiplot\n") 172 | } 173 | 174 | return nil 175 | } 176 | 177 | func pointAesGetter2(aes1, aes2 Aes) func(pt point) [2]value { 178 | return func(pt point) [2]value { 179 | return [2]value{pt.Get(aes1), pt.Get(aes2)} 180 | } 181 | } 182 | 183 | func isPowerOf2(x int) bool { 184 | return x > 0 && x&(x-1) == 0 185 | } 186 | 187 | func (p *gnuplotter) onePlot(pts []point, rowLabel, colLabel string) { 188 | if len(pts) == 0 { 189 | // Skip this plot. 190 | fmt.Fprintf(&p.code, "set multiplot next\n") 191 | return 192 | } 193 | 194 | var reset strings.Builder 195 | 196 | // Let gnuplot print scientific values on tick marks. This is much nicer 197 | // than putting it on the unit. 198 | setFormat := func(axis string, aes Aes) (scale func(float64) float64, label string) { 199 | kinds := pointsKinds(pts, aes) 200 | // Scale the values. We ask for no rescaling because we'll configure 201 | // gnuplot to do the scientific scaling for us. 202 | scale, _, _, label, _ = p.continuousScale(pts, aes, false) 203 | if kinds&kindRatio != 0 { 204 | // Format ratios as a percent delta. 205 | fmt.Fprintf(&p.code, "set format %s '%%+h%%%%'\n", axis) 206 | scale = func(x float64) float64 { return (x - 1) * 100 } 207 | label = "delta " + label 208 | 209 | // Always include 0. 210 | fmt.Fprintf(&p.code, "set %srange [*<0:0<*]\n", axis) 211 | 212 | // Draw a line at 0. The command uses the opposite axis. 213 | za := "x" 214 | if aes == AesX { 215 | za = "y" 216 | } 217 | fmt.Fprintf(&p.code, "set %szeroaxis dt 2\n", za) 218 | fmt.Fprintf(&reset, "unset %szeroaxis\n", za) 219 | } else { 220 | if base := p.logScale.Get(aes); base != 0 && isPowerOf2(base) { 221 | fmt.Fprintf(&p.code, "set format %s '%%.0b%%B'\n", axis) 222 | } else { 223 | fmt.Fprintf(&p.code, "set format %s '%%.0s%%c'\n", axis) 224 | } 225 | } 226 | return 227 | } 228 | xScale, xLabel := setFormat("x", AesX) 229 | yScale, yLabel := setFormat("y", AesY) 230 | 231 | // Set axis labels. Skip these if they would just duplicate the row/column 232 | // labels. This often happens with the default -row .unit -y .value 233 | // configuration, which will produce the same labels for the Y axis and the 234 | // row. 235 | // 236 | // TODO: If all X axes in a column have the same labels, but they differ 237 | // from the column label, consider showing only the X axis label in the 238 | // bottom-most graph. 239 | if colLabel != xLabel { 240 | fmt.Fprintf(&p.code, "set xlabel %s\n", gpString(xLabel)) 241 | } 242 | if rowLabel != yLabel { 243 | fmt.Fprintf(&p.code, "set ylabel %s\n", gpString(yLabel)) 244 | } 245 | 246 | // TODO: Should this be done up front? Then continuousScale would 247 | // have to understand summaries, but that's fine. 248 | // 249 | // TODO: Do something with the warnings. Allow configuring 250 | // confidence. 251 | pts, _ = transformSummarize(pts, AesY, p.confidence) 252 | 253 | // Set up for plotting ratios. 254 | kinds := pointsKinds(pts, AesY) 255 | var ratioPos, ratioNeg string 256 | if p.dvAes == AesY && kinds&kindRatio != 0 { 257 | // Check that all units have the same "better" direction. 258 | better := 0 259 | for i, unitName := range p.pointsUnits(pts) { 260 | better1 := p.units.GetBetter(unitName) 261 | if i == 0 { 262 | better = better1 263 | } else if better != better1 { 264 | better = 0 265 | break 266 | } 267 | } 268 | 269 | if better > 0 { 270 | ratioPos, ratioNeg = "red", "green" 271 | } else if better < 0 { 272 | ratioPos, ratioNeg = "green", "red" 273 | } 274 | } 275 | 276 | // Construct the key. 277 | // 278 | // TODO: There's some general n-way formulation of this, but it's not coming 279 | // to me right now. I think it's something like "everything is lp, but we 280 | // have a default linecolor/pointtype/pointsize when we're not iterating 281 | // over that dimension". 282 | var key grid2D[string] 283 | if p.nColors > 1 && p.nPoints > 1 { 284 | // Two separate columns for colors and points. 285 | // 286 | // TODO: Maybe not if they're totally correlated? 287 | // 288 | // TODO: Maybe only for values that appear in this plot? 289 | for color := range p.nColors { 290 | label := p.colorRev(color).StringValues() 291 | sample := fmt.Sprintf("with l title %s linecolor %d", gpString(label), color+1) 292 | key.Set(0, color, sample) 293 | } 294 | for point := range p.nPoints { 295 | label := p.pointRev(point).StringValues() 296 | sample := fmt.Sprintf("with p title %s lc 0 pointtype %d ps 2", gpString(label), point+1) 297 | key.Set(1, point, sample) 298 | } 299 | } else { 300 | // One key that combines colors and points. 301 | i := 0 302 | sliceBy(pts, pointAesGetter2(AesColor, AesPoint), 303 | func(seriesKey [2]value, pts []point) { 304 | title := "" 305 | for _, k := range seriesKey { 306 | str := k.StringValues() 307 | if title != "" && str != "" { 308 | title += " " 309 | } 310 | title += str 311 | } 312 | colorIdx := p.colorScale(pts[0]) + 1 313 | pointIdx := p.pointScale(pts[0]) + 1 314 | sample := fmt.Sprintf("with lp title %s linecolor %d pointtype %d ps 2", gpString(title), colorIdx, pointIdx) 315 | key.Set(0, i, sample) 316 | i++ 317 | }) 318 | } 319 | 320 | // Emit point data and build plot command. We build this up in several layers. 321 | const ( 322 | layerPos = iota 323 | layerNeg 324 | layerRange 325 | layerCenter 326 | maxLayers 327 | ) 328 | var plotArgs []string 329 | var data strings.Builder 330 | anyRange := false 331 | // TODO: Gnuplot does support "variable" point type, so it doesn't 332 | // necessarily have to separate series. But if we went down that road you'd 333 | // need some way to separate series that have the same color but different 334 | // points, which is probably not the default you want. We could have 335 | // different series separating and non-separating aesthetics even if they 336 | // control the same thing, or maybe a way to specify options on an aesthetic 337 | // for whether it separates series (some of them would always have to be one 338 | // way or another, possibly depending on the plot target), or maybe a way to 339 | // specify a particular mark with options specific to that mark (though that 340 | // complicates all sorts of things and makes the whole tool less automagic). 341 | for layer := range maxLayers { 342 | sliceBy(pts, pointAesGetter2(AesColor, AesPoint), 343 | func(seriesKey [2]value, pts []point) { 344 | colorIdx := p.colorScale(pts[0]) + 1 345 | pointIdx := p.pointScale(pts[0]) + 1 346 | 347 | if layer == layerRange { 348 | haveRange := false 349 | for _, pt := range pts { 350 | if !math.IsInf(pt.Get(AesY).summary.Lo, 0) { 351 | haveRange, anyRange = true, true 352 | break 353 | } 354 | } 355 | if !haveRange { 356 | return 357 | } 358 | 359 | // Emit range 360 | plotArg := fmt.Sprintf("'-' using 1:2:3 with filledcurves notitle fc linetype %d fs transparent solid 0.25", colorIdx) 361 | plotArgs = append(plotArgs, plotArg) 362 | 363 | for _, pt := range pts { 364 | x := pt.Get(AesX).val 365 | y := pt.Get(AesY).summary 366 | if !math.IsInf(y.Lo, 0) { 367 | fmt.Fprintf(&data, "%g %g %g\n", xScale(x), yScale(y.Lo), yScale(y.Hi)) 368 | } 369 | } 370 | fmt.Fprintf(&data, "e\n") 371 | return 372 | } 373 | 374 | // The other layers all use the same data and just different 375 | // plot options. 376 | plotArg := "'-' using 1:2" 377 | switch layer { 378 | case layerPos: 379 | if ratioPos == "" { 380 | return 381 | } 382 | plotArg += " with filledcurves below notitle fs transparent solid 0.1 fc '" + ratioPos + "' lw 0" 383 | case layerNeg: 384 | if ratioNeg == "" { 385 | return 386 | } 387 | plotArg += " with filledcurves above notitle fs transparent solid 0.1 fc '" + ratioNeg + "' lw 0" 388 | case layerCenter: 389 | plotArg += fmt.Sprintf(" with lp notitle linecolor %d pointtype %d ps 2", colorIdx, pointIdx) 390 | } 391 | 392 | // Emit center curve. 393 | plotArgs = append(plotArgs, plotArg) 394 | for _, pt := range pts { 395 | fmt.Fprintf(&data, "%g %g\n", xScale(pt.Get(AesX).val), yScale(pt.Get(AesY).val)) 396 | } 397 | fmt.Fprintf(&data, "e\n") 398 | }) 399 | } 400 | 401 | if anyRange { 402 | // Add a legend entry for the range. 403 | sample := fmt.Sprintf("with filledcurves title '%v%% confidence' fc linetype 0 fs transparent solid 0.25", p.confidence*100) 404 | key.Set(0, key.h, sample) 405 | } 406 | 407 | // Configure key. 408 | if key.w > 1 { 409 | fmt.Fprintf(&p.code, "set key maxrows %d\n", key.h) 410 | defer fmt.Fprintf(&p.code, "set key default\n") 411 | } 412 | for x := range key.w { 413 | for y := range key.h { 414 | sample, ok := key.Get(x, y) 415 | if ok { 416 | plotArgs = append(plotArgs, "keyentry "+sample) 417 | } else { 418 | // Gnuplot will trim away any notitle keys at the end, so we 419 | // have to produce "real" key entries, at least for the very 420 | // last one. 421 | plotArgs = append(plotArgs, "keyentry title ' '") 422 | } 423 | } 424 | } 425 | 426 | fmt.Fprintf(&p.code, "plot %s\n", strings.Join(plotArgs, ", ")) 427 | 428 | p.code.WriteString(data.String()) 429 | 430 | p.code.WriteString(reset.String()) 431 | } 432 | 433 | // gpString returns s escaped for Gnuplot 434 | func gpString(s string) string { 435 | // I can't find any documentation on Gnuplot's escape syntax, but as far as 436 | // I can tell, it's compatible with Go's escaping rules. 437 | return strconv.Quote(s) 438 | } 439 | 440 | // grid2D is a sparse mapping from 2-D indexes to V. 441 | type grid2D[V any] struct { 442 | w, h int 443 | vals map[grid2DKey]V 444 | } 445 | 446 | type grid2DKey struct{ x, y int } 447 | 448 | func (g *grid2D[V]) Set(x, y int, v V) { 449 | if g.vals == nil { 450 | g.vals = make(map[grid2DKey]V) 451 | } 452 | g.vals[grid2DKey{x, y}] = v 453 | g.w = max(g.w, x+1) 454 | g.h = max(g.h, y+1) 455 | } 456 | 457 | func (g *grid2D[V]) Get(x, y int) (V, bool) { 458 | v, ok := g.vals[grid2DKey{x, y}] 459 | return v, ok 460 | } 461 | --------------------------------------------------------------------------------