benchstat: split table generation into table.go, text.go, html.go

Change-Id: I33d4b6006f4df6b253133c4467488fd6da68a34f
Reviewed-on: https://go-review.googlesource.com/35937
Reviewed-by: Quentin Smith <quentin@golang.org>
diff --git a/cmd/benchstat/data.go b/cmd/benchstat/data.go
index c458b9f..c6ac4ec 100644
--- a/cmd/benchstat/data.go
+++ b/cmd/benchstat/data.go
@@ -59,7 +59,7 @@
 // FormatDiff computes and formats the percent variation of max and min compared to mean.
 // If b.Mean or b.Max is zero, FormatDiff returns an empty string.
 func (m *Metrics) FormatDiff() string {
-	if m.Mean == 0 || m.Max == 0 {
+	if m == nil || m.Mean == 0 || m.Max == 0 {
 		return ""
 	}
 	diff := 1 - m.Min/m.Mean
@@ -71,6 +71,9 @@
 
 // Format returns a textual formatting of "Mean ±Diff" using scaler.
 func (m *Metrics) Format(scaler Scaler) string {
+	if m == nil {
+		return ""
+	}
 	mean := m.FormatMean(scaler)
 	diff := m.FormatDiff()
 	if diff == "" {
diff --git a/cmd/benchstat/html.go b/cmd/benchstat/html.go
new file mode 100644
index 0000000..c4280ab
--- /dev/null
+++ b/cmd/benchstat/html.go
@@ -0,0 +1,39 @@
+// Copyright 2017 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 main
+
+import (
+	"bytes"
+	"fmt"
+	"html"
+)
+
+// FormatHTML appends an HTML formatting of the tables to buf.
+func FormatHTML(buf *bytes.Buffer, tables []*Table) {
+	var textTables [][]*textRow
+	for _, t := range tables {
+		textTables = append(textTables, toText(t))
+	}
+
+	for i, table := range textTables {
+		if i > 0 {
+			fmt.Fprintf(buf, "\n")
+		}
+		fmt.Fprintf(buf, "<style>.benchstat tbody td:nth-child(1n+2) { text-align: right; padding: 0em 1em; }</style>\n")
+		fmt.Fprintf(buf, "<table class='benchstat'>\n")
+		printRow := func(row *textRow, tag string) {
+			fmt.Fprintf(buf, "<tr>")
+			for _, cell := range row.cols {
+				fmt.Fprintf(buf, "<%s>%s</%s>", tag, html.EscapeString(cell), tag)
+			}
+			fmt.Fprintf(buf, "\n")
+		}
+		printRow(table[0], "th")
+		for _, row := range table[1:] {
+			printRow(row, "td")
+		}
+		fmt.Fprintf(buf, "</table>\n")
+	}
+}
diff --git a/cmd/benchstat/main.go b/cmd/benchstat/main.go
index 5813fd3..42c4c18 100644
--- a/cmd/benchstat/main.go
+++ b/cmd/benchstat/main.go
@@ -94,14 +94,10 @@
 	"bytes"
 	"flag"
 	"fmt"
-	"html"
 	"io/ioutil"
 	"log"
 	"os"
 	"strings"
-	"unicode/utf8"
-
-	"golang.org/x/perf/internal/stats"
 )
 
 func usage() {
@@ -128,24 +124,6 @@
 	"ttest":  TTest,
 }
 
-type row struct {
-	cols []string
-}
-
-func newRow(cols ...string) *row {
-	return &row{cols: cols}
-}
-
-func (r *row) add(col string) {
-	r.cols = append(r.cols, col)
-}
-
-func (r *row) trim() {
-	for len(r.cols) > 0 && r.cols[len(r.cols)-1] == "" {
-		r.cols = r.cols[:len(r.cols)-1]
-	}
-}
-
 func main() {
 	log.SetPrefix("benchstat: ")
 	log.SetFlags(0)
@@ -156,7 +134,6 @@
 		flag.Usage()
 	}
 
-	// Read in benchmark data.
 	c := new(Collection)
 	for _, file := range flag.Args() {
 		data, err := ioutil.ReadFile(file)
@@ -165,210 +142,14 @@
 		}
 		c.AddConfig(file, data)
 	}
-	for _, m := range c.Metrics {
-		m.computeStats()
-	}
 
-	var tables [][]*row
-	switch len(c.Configs) {
-	case 2:
-		before, after := c.Configs[0], c.Configs[1]
-		key := Key{}
-		for _, key.Unit = range c.Units {
-			var table []*row
-			metric := metricOf(key.Unit)
-			for _, key.Benchmark = range c.Benchmarks {
-				key.Config = before
-				old := c.Metrics[key]
-				key.Config = after
-				new := c.Metrics[key]
-				if old == nil || new == nil {
-					continue
-				}
-				if len(table) == 0 {
-					table = append(table, newRow("name", "old "+metric, "new "+metric, "delta"))
-				}
-
-				pval, testerr := deltaTest(old, new)
-
-				scaler := NewScaler(old.Mean, old.Unit)
-				row := newRow(key.Benchmark, old.Format(scaler), new.Format(scaler), "~   ")
-				if testerr != nil {
-					row.add(fmt.Sprintf("(%s)", testerr))
-				} else if pval < *flagAlpha {
-					row.cols[3] = fmt.Sprintf("%+.2f%%", ((new.Mean/old.Mean)-1.0)*100.0)
-				}
-				if len(row.cols) == 4 && pval != -1 {
-					row.add(fmt.Sprintf("(p=%0.3f n=%d+%d)", pval, len(old.RValues), len(new.RValues)))
-				}
-				table = append(table, row)
-			}
-			if len(table) > 0 {
-				table = addGeomean(table, c, key.Unit, true)
-				tables = append(tables, table)
-			}
-		}
-
-	default:
-		key := Key{}
-		for _, key.Unit = range c.Units {
-			var table []*row
-			metric := metricOf(key.Unit)
-
-			if len(c.Configs) > 1 {
-				hdr := newRow("name \\ " + metric)
-				for _, config := range c.Configs {
-					hdr.add(config)
-				}
-				table = append(table, hdr)
-			} else {
-				table = append(table, newRow("name", metric))
-			}
-
-			for _, key.Benchmark = range c.Benchmarks {
-				row := newRow(key.Benchmark)
-				var scaler Scaler
-				for _, key.Config = range c.Configs {
-					m := c.Metrics[key]
-					if m == nil {
-						row.add("")
-						continue
-					}
-					if scaler == nil {
-						scaler = NewScaler(m.Mean, m.Unit)
-					}
-					row.add(m.Format(scaler))
-				}
-				row.trim()
-				if len(row.cols) > 1 {
-					table = append(table, row)
-				}
-			}
-			table = addGeomean(table, c, key.Unit, false)
-			tables = append(tables, table)
-		}
-	}
-
-	numColumn := 0
-	for _, table := range tables {
-		for _, row := range table {
-			if numColumn < len(row.cols) {
-				numColumn = len(row.cols)
-			}
-		}
-	}
-
-	max := make([]int, numColumn)
-	for _, table := range tables {
-		for _, row := range table {
-			for i, s := range row.cols {
-				n := utf8.RuneCountInString(s)
-				if max[i] < n {
-					max[i] = n
-				}
-			}
-		}
-	}
+	tables := c.Tables(deltaTest)
 
 	var buf bytes.Buffer
-	for i, table := range tables {
-		if i > 0 {
-			fmt.Fprintf(&buf, "\n")
-		}
-
-		if *flagHTML {
-			fmt.Fprintf(&buf, "<style>.benchstat tbody td:nth-child(1n+2) { text-align: right; padding: 0em 1em; }</style>\n")
-			fmt.Fprintf(&buf, "<table class='benchstat'>\n")
-			printRow := func(row *row, tag string) {
-				fmt.Fprintf(&buf, "<tr>")
-				for _, cell := range row.cols {
-					fmt.Fprintf(&buf, "<%s>%s</%s>", tag, html.EscapeString(cell), tag)
-				}
-				fmt.Fprintf(&buf, "\n")
-			}
-			printRow(table[0], "th")
-			for _, row := range table[1:] {
-				printRow(row, "td")
-			}
-			fmt.Fprintf(&buf, "</table>\n")
-			continue
-		}
-
-		// headings
-		row := table[0]
-		for i, s := range row.cols {
-			switch i {
-			case 0:
-				fmt.Fprintf(&buf, "%-*s", max[i], s)
-			default:
-				fmt.Fprintf(&buf, "  %-*s", max[i], s)
-			case len(row.cols) - 1:
-				fmt.Fprintf(&buf, "  %s\n", s)
-			}
-		}
-
-		// data
-		for _, row := range table[1:] {
-			for i, s := range row.cols {
-				switch i {
-				case 0:
-					fmt.Fprintf(&buf, "%-*s", max[i], s)
-				default:
-					if i == len(row.cols)-1 && len(s) > 0 && s[0] == '(' {
-						// Left-align p value.
-						fmt.Fprintf(&buf, "  %s", s)
-						break
-					}
-					fmt.Fprintf(&buf, "  %*s", max[i], s)
-				}
-			}
-			fmt.Fprintf(&buf, "\n")
-		}
+	if *flagHTML {
+		FormatHTML(&buf, tables)
+	} else {
+		FormatText(&buf, tables)
 	}
-
 	os.Stdout.Write(buf.Bytes())
 }
-
-func addGeomean(table []*row, c *Collection, unit string, delta bool) []*row {
-	if !*flagGeomean {
-		return table
-	}
-
-	row := newRow("[Geo mean]")
-	key := Key{Unit: unit}
-	geomeans := []float64{}
-	for _, key.Config = range c.Configs {
-		var means []float64
-		for _, key.Benchmark = range c.Benchmarks {
-			m := c.Metrics[key]
-			if m != nil {
-				means = append(means, m.Mean)
-			}
-		}
-		if len(means) == 0 {
-			row.add("")
-			delta = false
-		} else {
-			geomean := stats.GeoMean(means)
-			geomeans = append(geomeans, geomean)
-			row.add(NewScaler(geomean, unit)(geomean) + "     ")
-		}
-	}
-	if delta {
-		row.add(fmt.Sprintf("%+.2f%%", ((geomeans[1]/geomeans[0])-1.0)*100.0))
-	}
-	return append(table, row)
-}
-
-func metricOf(unit string) string {
-	switch unit {
-	case "ns/op":
-		return "time/op"
-	case "B/op":
-		return "alloc/op"
-	case "MB/s":
-		return "speed"
-	default:
-		return unit
-	}
-}
diff --git a/cmd/benchstat/table.go b/cmd/benchstat/table.go
new file mode 100644
index 0000000..51be88c
--- /dev/null
+++ b/cmd/benchstat/table.go
@@ -0,0 +1,149 @@
+// Copyright 2017 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 main
+
+import (
+	"fmt"
+
+	"golang.org/x/perf/internal/stats"
+)
+
+// A Table is a table for display in the benchstat output.
+type Table struct {
+	Metric  string
+	Configs []string
+	Rows    []*Row
+}
+
+// A Row is a table row for display in the benchstat output.
+type Row struct {
+	Benchmark string     // benchmark name
+	Scaler    Scaler     // formatter for stats means
+	Metrics   []*Metrics // columns of statistics (nil slice entry means no data)
+	Delta     string     // formatted percent change
+	Note      string     // additional information
+	Same      bool       // likely no change
+}
+
+// Tables returns tables comparing the benchmarks in the collection.
+func (c *Collection) Tables(deltaTest DeltaTest) []*Table {
+	// Update statistics.
+	for _, m := range c.Metrics {
+		m.computeStats()
+	}
+
+	var tables []*Table
+	key := Key{}
+	for _, key.Unit = range c.Units {
+		table := new(Table)
+		table.Configs = c.Configs
+		table.Metric = metricOf(key.Unit)
+		for _, key.Benchmark = range c.Benchmarks {
+			row := &Row{Benchmark: key.Benchmark}
+
+			for _, key.Config = range c.Configs {
+				m := c.Metrics[key]
+				row.Metrics = append(row.Metrics, m)
+				if m == nil {
+					continue
+				}
+				if row.Scaler == nil {
+					row.Scaler = NewScaler(m.Mean, m.Unit)
+				}
+			}
+
+			// If there are only two configs being compared, add stats.
+			// If one is missing, omit line entirely.
+			// TODO: Control this better.
+			if len(c.Configs) == 2 {
+				k0 := key
+				k0.Config = c.Configs[0]
+				k1 := key
+				k1.Config = c.Configs[1]
+				old := c.Metrics[k0]
+				new := c.Metrics[k1]
+				if old == nil || new == nil {
+					continue
+				}
+				pval, testerr := deltaTest(old, new)
+				row.Delta = "~"
+				if testerr == stats.ErrZeroVariance {
+					row.Note = "(zero variance)"
+				} else if testerr == stats.ErrSampleSize {
+					row.Note = "(too few samples)"
+				} else if testerr == stats.ErrSamplesEqual {
+					row.Note = "(all equal)"
+				} else if testerr != nil {
+					row.Note = fmt.Sprintf("(%s)", testerr)
+				} else if pval < *flagAlpha {
+					row.Delta = fmt.Sprintf("%+.2f%%", ((new.Mean/old.Mean)-1.0)*100.0)
+				}
+				if row.Note == "" && pval != -1 {
+					row.Note = fmt.Sprintf("(p=%0.3f n=%d+%d)", pval, len(old.RValues), len(new.RValues))
+				}
+			}
+
+			table.Rows = append(table.Rows, row)
+		}
+
+		if len(table.Rows) > 0 {
+			if *flagGeomean {
+				addGeomean(c, table, key.Unit, len(c.Configs) == 2)
+			}
+			tables = append(tables, table)
+		}
+	}
+
+	return tables
+}
+
+// metricOf returns the name of the metric with the given unit.
+func metricOf(unit string) string {
+	switch unit {
+	case "ns/op":
+		return "time/op"
+	case "B/op":
+		return "alloc/op"
+	case "MB/s":
+		return "speed"
+	default:
+		return unit
+	}
+}
+
+// addGeomean adds a "geomean" row to the table,
+// showing the geometric mean of all the benchmarks.
+func addGeomean(c *Collection, t *Table, unit string, delta bool) {
+	row := &Row{Benchmark: "[Geo mean]"}
+	key := Key{Unit: unit}
+	geomeans := []float64{}
+	for _, key.Config = range c.Configs {
+		var means []float64
+		for _, key.Benchmark = range c.Benchmarks {
+			m := c.Metrics[key]
+			if m != nil {
+				means = append(means, m.Mean)
+			}
+		}
+		if len(means) == 0 {
+			row.Metrics = append(row.Metrics, nil)
+			delta = false
+		} else {
+			geomean := stats.GeoMean(means)
+			geomeans = append(geomeans, geomean)
+			if row.Scaler == nil {
+				row.Scaler = NewScaler(geomean, unit)
+			}
+			row.Metrics = append(row.Metrics, &Metrics{
+				Unit: unit,
+				Mean: geomean,
+			})
+		}
+	}
+	if delta {
+		row.Delta = fmt.Sprintf("%+.2f%%", ((geomeans[1]/geomeans[0])-1.0)*100.0)
+	}
+	t.Rows = append(t.Rows, row)
+}
diff --git a/cmd/benchstat/text.go b/cmd/benchstat/text.go
new file mode 100644
index 0000000..2654344
--- /dev/null
+++ b/cmd/benchstat/text.go
@@ -0,0 +1,126 @@
+// Copyright 2017 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 main
+
+import (
+	"bytes"
+	"fmt"
+	"unicode/utf8"
+)
+
+// FormatText appends a fixed-width text formatting of the tables to buf.
+func FormatText(buf *bytes.Buffer, tables []*Table) {
+	var textTables [][]*textRow
+	for _, t := range tables {
+		textTables = append(textTables, toText(t))
+	}
+
+	var max []int
+	for _, table := range textTables {
+		for _, row := range table {
+			for len(max) < len(row.cols) {
+				max = append(max, 0)
+			}
+			for i, s := range row.cols {
+				n := utf8.RuneCountInString(s)
+				if max[i] < n {
+					max[i] = n
+				}
+			}
+		}
+	}
+
+	for i, table := range textTables {
+		if i > 0 {
+			fmt.Fprintf(buf, "\n")
+		}
+
+		// headings
+		row := table[0]
+		for i, s := range row.cols {
+			switch i {
+			case 0:
+				fmt.Fprintf(buf, "%-*s", max[i], s)
+			default:
+				fmt.Fprintf(buf, "  %-*s", max[i], s)
+			case len(row.cols) - 1:
+				fmt.Fprintf(buf, "  %s\n", s)
+			}
+		}
+
+		// data
+		for _, row := range table[1:] {
+			for i, s := range row.cols {
+				switch i {
+				case 0:
+					fmt.Fprintf(buf, "%-*s", max[i], s)
+				default:
+					if i == len(row.cols)-1 && len(s) > 0 && s[0] == '(' {
+						// Left-align p value.
+						fmt.Fprintf(buf, "  %s", s)
+						break
+					}
+					fmt.Fprintf(buf, "  %*s", max[i], s)
+				}
+			}
+			fmt.Fprintf(buf, "\n")
+		}
+	}
+}
+
+// A textRow is a row of printed text columns.
+type textRow struct {
+	cols []string
+}
+
+func newTextRow(cols ...string) *textRow {
+	return &textRow{cols: cols}
+}
+
+func (r *textRow) add(col string) {
+	r.cols = append(r.cols, col)
+}
+
+func (r *textRow) trim() {
+	for len(r.cols) > 0 && r.cols[len(r.cols)-1] == "" {
+		r.cols = r.cols[:len(r.cols)-1]
+	}
+}
+
+// toText converts the Table to a textual grid of cells,
+// which can then be printed in fixed-width output.
+func toText(t *Table) []*textRow {
+	var textRows []*textRow
+	switch len(t.Configs) {
+	case 1:
+		textRows = append(textRows, newTextRow("name", t.Metric))
+	case 2:
+		textRows = append(textRows, newTextRow("name", "old "+t.Metric, "new "+t.Metric, "delta"))
+	default:
+		row := newTextRow("name \\ " + t.Metric)
+		row.cols = append(row.cols, t.Configs...)
+		textRows = append(textRows, row)
+	}
+
+	for _, row := range t.Rows {
+		text := newTextRow(row.Benchmark)
+		for _, m := range row.Metrics {
+			text.cols = append(text.cols, m.Format(row.Scaler))
+		}
+		if len(t.Configs) == 2 {
+			delta := row.Delta
+			if delta == "~" {
+				delta = "~   "
+			}
+			text.cols = append(text.cols, delta)
+			text.cols = append(text.cols, row.Note)
+		}
+		textRows = append(textRows, text)
+	}
+	for _, r := range textRows {
+		r.trim()
+	}
+	return textRows
+}