github.com/jgbaldwinbrown/perf@v0.1.1/benchstat/text.go (about) 1 // Copyright 2017 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 benchstat 6 7 import ( 8 "encoding/csv" 9 "fmt" 10 "io" 11 "path/filepath" 12 "strings" 13 "unicode/utf8" 14 ) 15 16 // FormatText appends a fixed-width text formatting of the tables to w. 17 func FormatText(w io.Writer, tables []*Table) { 18 var textTables [][]*textRow 19 for _, t := range tables { 20 textTables = append(textTables, toText(t)) 21 } 22 23 var max []int 24 for _, table := range textTables { 25 for _, row := range table { 26 if len(row.cols) == 1 { 27 // Header row 28 continue 29 } 30 for len(max) < len(row.cols) { 31 max = append(max, 0) 32 } 33 for i, s := range row.cols { 34 n := utf8.RuneCountInString(s) 35 if max[i] < n { 36 max[i] = n 37 } 38 } 39 } 40 } 41 42 for i, table := range textTables { 43 if i > 0 { 44 fmt.Fprintf(w, "\n") 45 } 46 47 // headings 48 row := table[0] 49 for i, s := range row.cols { 50 switch i { 51 case 0: 52 fmt.Fprintf(w, "%-*s", max[i], s) 53 default: 54 fmt.Fprintf(w, " %-*s", max[i], s) 55 case len(row.cols) - 1: 56 fmt.Fprintf(w, " %s\n", s) 57 } 58 } 59 60 // data 61 for _, row := range table[1:] { 62 for i, s := range row.cols { 63 switch { 64 case len(row.cols) == 1: 65 // Header row 66 fmt.Fprint(w, s) 67 case i == 0: 68 fmt.Fprintf(w, "%-*s", max[i], s) 69 default: 70 if i == len(row.cols)-1 && len(s) > 0 && s[0] == '(' { 71 // Left-align p value. 72 fmt.Fprintf(w, " %s", s) 73 break 74 } 75 fmt.Fprintf(w, " %*s", max[i], s) 76 } 77 } 78 fmt.Fprintf(w, "\n") 79 } 80 } 81 } 82 83 func must(e error) { 84 if e != nil { 85 panic(e) 86 } 87 } 88 89 // FormatCSV appends a CSV formatting of the tables to w. 90 // norange suppresses the range columns. 91 func FormatCSV(w io.Writer, tables []*Table, norange bool) { 92 var textTables [][]*textRow 93 for _, t := range tables { 94 textTables = append(textTables, toCSV(t, norange)) 95 } 96 97 for i, table := range textTables { 98 if i > 0 { 99 fmt.Fprintf(w, "\n") 100 } 101 csvw := csv.NewWriter(w) 102 103 // headings 104 row := table[0] 105 must(csvw.Write(row.cols)) 106 107 // data 108 for _, row := range table[1:] { 109 must(csvw.Write(row.cols)) 110 } 111 csvw.Flush() 112 } 113 } 114 115 // trimCommonPathPrefix returns a string slice with common 116 // path-separator-terminated prefixes removed. Empty strings 117 // are ignored and a singleton non-empty string is left unchanged 118 func trimCommonPathPrefix(ss []string) []string { 119 commonPrefixLen := -1 120 commonPrefix := "" 121 trimCommonPrefix := false // true if more than one data row w/ non-empty title 122 123 // begin finding common byte prefix (could end on partial rune) 124 for _, s := range ss { 125 if s == "" { 126 continue 127 } 128 if commonPrefixLen == -1 { 129 commonPrefix = s 130 commonPrefixLen = len(s) 131 continue 132 } 133 trimCommonPrefix = true // More than one not-empty 134 if commonPrefixLen > len(s) { 135 commonPrefixLen = len(s) 136 commonPrefix = commonPrefix[0:commonPrefixLen] 137 } 138 for j := 0; j < commonPrefixLen; j++ { 139 if commonPrefix[j] != s[j] { 140 commonPrefixLen = j 141 commonPrefix = commonPrefix[0:commonPrefixLen] 142 break 143 } 144 } 145 } 146 if !trimCommonPrefix || commonPrefixLen == 0 { 147 return ss 148 } 149 // trim to include last path separator. 150 commonPrefixLen = 1 + strings.LastIndex(commonPrefix, string(filepath.Separator)) 151 if commonPrefixLen == 0 { 152 // No separator found means commonPrefixLen = 1 + (-1) == 0 153 return ss 154 } 155 156 rs := []string{} 157 for _, s := range ss { 158 if s == "" { 159 rs = append(rs, "") 160 } else { 161 rs = append(rs, s[commonPrefixLen:]) 162 } 163 } 164 return rs 165 } 166 167 // A textRow is a row of printed text columns. 168 type textRow struct { 169 cols []string 170 } 171 172 func newTextRow(cols ...string) *textRow { 173 return &textRow{cols: cols} 174 } 175 176 // newTextRowDelta returns a labeled row of text, with "±" inserted after 177 // each member of "cols" unless norange is true. 178 func newTextRowDelta(norange bool, label string, cols ...string) *textRow { 179 newcols := []string{} 180 newcols = append(newcols, label) 181 for _, s := range cols { 182 newcols = append(newcols, s) 183 if !norange { 184 newcols = append(newcols, "±") 185 } 186 } 187 return &textRow{cols: newcols} 188 } 189 190 func (r *textRow) add(col string) { 191 r.cols = append(r.cols, col) 192 } 193 194 func (r *textRow) trim() { 195 for len(r.cols) > 0 && r.cols[len(r.cols)-1] == "" { 196 r.cols = r.cols[:len(r.cols)-1] 197 } 198 } 199 200 // toText converts the Table to a textual grid of cells, 201 // which can then be printed in fixed-width output. 202 func toText(t *Table) []*textRow { 203 var textRows []*textRow 204 switch len(t.Configs) { 205 case 1: 206 textRows = append(textRows, newTextRow("name", t.Metric)) 207 case 2: 208 textRows = append(textRows, newTextRow("name", "old "+t.Metric, "new "+t.Metric, "delta")) 209 default: 210 row := newTextRow("name \\ " + t.Metric) 211 row.cols = append(row.cols, t.Configs...) // TODO Should this also trim common path prefix? (see toCSV) 212 textRows = append(textRows, row) 213 } 214 215 var group string 216 217 for _, row := range t.Rows { 218 if row.Group != group { 219 group = row.Group 220 textRows = append(textRows, newTextRow(group)) 221 } 222 text := newTextRow(row.Benchmark) 223 for _, m := range row.Metrics { 224 text.cols = append(text.cols, m.Format(row.Scaler)) 225 } 226 if len(t.Configs) == 2 { 227 delta := row.Delta 228 if delta == "~" { 229 delta = "~ " 230 } 231 text.cols = append(text.cols, delta) 232 text.cols = append(text.cols, row.Note) 233 } 234 textRows = append(textRows, text) 235 } 236 for _, r := range textRows { 237 r.trim() 238 } 239 return textRows 240 } 241 242 // toCSV converts the Table to a slice of rows containing CSV-separated data 243 // If norange is true, suppress the range information for each data item. 244 // If norange is false, insert a "±" in the appropriate columns of the header row. 245 func toCSV(t *Table, norange bool) []*textRow { 246 var textRows []*textRow 247 units := "" 248 if len(t.Rows) > 0 && len(t.Rows[0].Metrics) > 0 { 249 units = " (" + t.Rows[0].Metrics[0].Unit + ")" 250 } 251 switch len(t.Configs) { 252 case 1: 253 textRows = append(textRows, newTextRowDelta(norange, "name", t.Metric+units)) 254 case 2: 255 textRows = append(textRows, newTextRowDelta(norange, "name", "old "+t.Metric+units, "new "+t.Metric+units, "delta")) 256 default: 257 rowname := "name \\ " + t.Metric 258 row := newTextRowDelta(norange, rowname+units, trimCommonPathPrefix(t.Configs)...) 259 textRows = append(textRows, row) 260 } 261 262 var group string 263 264 for _, row := range t.Rows { 265 if row.Group != group { 266 group = row.Group 267 textRows = append(textRows, newTextRow(group)) 268 } 269 text := newTextRow(row.Benchmark) 270 for _, m := range row.Metrics { 271 mean := fmt.Sprintf("%.5E", m.Mean) 272 diff := m.FormatDiff() 273 if m.Unit == "" { 274 mean = "" 275 diff = "" 276 } 277 text.cols = append(text.cols, mean) 278 if !norange { 279 text.cols = append(text.cols, diff) 280 } 281 } 282 if len(t.Configs) == 2 { 283 delta := row.Delta 284 text.cols = append(text.cols, delta) 285 text.cols = append(text.cols, row.Note) 286 } 287 textRows = append(textRows, text) 288 } 289 for _, r := range textRows { 290 r.trim() 291 } 292 return textRows 293 }