| // See README.md |
| package main |
| |
| import ( |
| "flag" |
| "fmt" |
| "io" |
| "log" |
| "math" |
| "os" |
| "sort" |
| "strings" |
| "text/tabwriter" |
| |
| "go.skia.org/infra/go/paramtools" |
| "go.skia.org/infra/perf/go/ingest/format" |
| "go.skia.org/infra/perf/go/ingest/parser" |
| "go.skia.org/infra/perf/go/samplestats" |
| ) |
| |
| // sortNames maps --sort flag values to the matching Order function. |
| var sortNames = map[string]samplestats.Order{ |
| "name": samplestats.ByName, |
| "delta": samplestats.ByDelta, |
| } |
| |
| // validTests maps --test values to the right Test type. |
| var validTests = map[string]samplestats.Test{ |
| "t-test": samplestats.TTest, |
| "t": samplestats.TTest, |
| "ttest": samplestats.TTest, |
| "u-test": samplestats.UTest, |
| "u": samplestats.UTest, |
| "utest": samplestats.UTest, |
| } |
| |
| func main() { |
| actualMain(os.Stdout) |
| } |
| |
| func actualMain(stdout io.Writer) { |
| // Use a flagSet so we don't end up with the glog cluttering up the flags. |
| flagSet := flag.NewFlagSet("nanostat", flag.ContinueOnError) |
| flagSet.SetOutput(os.Stdout) |
| |
| flagAlpha := flagSet.Float64("alpha", 0.05, "Consider a change significant if p < α. Must be > 0.") |
| flagSort := flagSet.String("sort", "delta", "Sort by `order`: [-]delta, [-]name") |
| flagIQRR := flagSet.Bool("iqrr", false, "If true then remove outliers in the samples using the Interquartile Range Rule.") |
| flagAll := flagSet.Bool("all", false, "If true then include insignificant changes in output.") |
| flagTest := flagSet.String("test", string(samplestats.UTest), "The type of test to do, 'utest' for Mann-Whitney U test, and 'ttest' for a Two Sample Welch T test.") |
| |
| usage := func() { |
| fmt.Printf("usage: nanostat [options] old.json new.json\n") |
| fmt.Printf("options:\n") |
| flagSet.PrintDefaults() |
| fmt.Printf("\nSee https://github.com/google/skia-buildbot/tree/main/perf/nanostat/README.md for more details.\n") |
| os.Exit(2) |
| } |
| |
| flagSet.Usage = usage |
| |
| // Ignore the output since failures will call our usage() which exits. |
| _ = flagSet.Parse(os.Args[1:]) |
| |
| sortName := *flagSort |
| reverse := false |
| if strings.HasPrefix(sortName, "-") { |
| reverse = true |
| sortName = sortName[1:] |
| } |
| order, orderOK := sortNames[sortName] |
| test, testOK := validTests[*flagTest] |
| if flagSet.NArg() != 2 || !orderOK || !testOK { |
| usage() |
| } |
| |
| if reverse { |
| order = samplestats.Reverse(order) |
| } |
| |
| config := samplestats.Config{ |
| Alpha: *flagAlpha, |
| IQRR: *flagIQRR, |
| All: *flagAll, |
| Test: test, |
| Order: order, |
| } |
| beforeSamples := loadFileByName(flagSet.Args()[0]) |
| afterSamples := loadFileByName(flagSet.Args()[1]) |
| result := samplestats.Analyze(config, beforeSamples, afterSamples) |
| |
| if result.Skipped > 0 { |
| _, err := fmt.Fprintf(stdout, "\nSkipped: %d \n", result.Skipped) |
| if err != nil { |
| log.Fatal(err) |
| } |
| } |
| |
| if len(result.Rows) > 0 { |
| rowsAsTabbedStrings := formatRows(config, result.Rows) |
| tw := tabwriter.NewWriter(stdout, 0, 0, 2, ' ', tabwriter.AlignRight) |
| for _, line := range rowsAsTabbedStrings { |
| _, err := fmt.Fprintln(tw, line) |
| if err != nil { |
| log.Fatal(err) |
| } |
| } |
| err := tw.Flush() |
| if err != nil { |
| log.Fatal(err) |
| } |
| } else { |
| if !config.All { |
| _, err := fmt.Fprintln(stdout, "No significant deltas found. Add --all to see non-significant results.") |
| if err != nil { |
| log.Fatal(err) |
| } |
| } |
| } |
| } |
| |
| func formatRows(config samplestats.Config, rows []samplestats.Row) []string { |
| ret := make([]string, 0, len(rows)+1) |
| |
| // Find all the keys that have more than one value. Such as 'config', which |
| // might be ['gl', 'gles'], which means we have different results for each |
| // config, and the config value needs to be printed when we display results. |
| ps := paramtools.NewParamSet() |
| for _, row := range rows { |
| ps.AddParams(row.Params) |
| } |
| |
| // Remove keys we know we don't want, "test", and keys we want at the end of |
| // the list, "name". |
| delete(ps, "test") |
| delete(ps, "name") |
| importantKeys := []string{} |
| for key, values := range ps { |
| // If a key has more than one value than it's important we display it. |
| if len(values) > 1 { |
| importantKeys = append(importantKeys, key) |
| } |
| } |
| sort.Strings(importantKeys) |
| |
| // The name of the test always goes last. |
| importantKeys = append(importantKeys, "test", "name") |
| |
| header := "old\tnew\tdelta\tstats\t\t " + strings.Join(importantKeys, "\t ") |
| |
| ret = append(ret, header) |
| |
| for _, row := range rows { |
| delta := "~" |
| if !math.IsNaN(row.Delta) { |
| delta = fmt.Sprintf("%.0f%%", row.Delta) |
| } |
| |
| // Create the full name from all the important keys. |
| fullName := []string{} |
| for _, key := range importantKeys { |
| fullName = append(fullName, row.Params[key]) |
| } |
| ret = append(ret, fmt.Sprintf("%0.2f ± %2.0f%%\t%0.2f ± %2.0f%%\t%s %s\t(p=%0.3f,\tn=%d+%d)\t %s", |
| row.Samples[0].Mean, |
| row.Samples[0].Percent, |
| row.Samples[1].Mean, |
| row.Samples[1].Percent, |
| delta, |
| row.Note, |
| row.P, |
| len(row.Samples[0].Values), |
| len(row.Samples[1].Values), |
| strings.Join(fullName, "\t "), |
| )) |
| } |
| return ret |
| } |
| |
| func loadFileByName(filename string) parser.SamplesSet { |
| f, err := os.Open(filename) |
| if err != nil { |
| log.Fatal(err) |
| } |
| defer f.Close() |
| benchData, err := format.ParseLegacyFormat(f) |
| if err != nil { |
| log.Fatal(err) |
| } |
| return parser.GetSamplesFromLegacyFormat(benchData) |
| } |