diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..b294062 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,81 @@ +# Benchmarking + +The `bench` command provides a set of tools used for benchmarking the performance of pixel under various scenarios. +It is intended to be a development tool for comparing the performance of new implementations in pixel against previous iterations. + +## Usage + +List available benchmarks +``` +go run main.go bench ls +``` + +Run a benchmark +``` +go run main.go bench run [names...] +``` + +Write benchmark stats to a file +``` +go run main.go bench run [names...] -o my-stats.json +``` + +## Profiling +Run benchmark with cpu/mem profiling enabled +``` +go run main.go bench run [names...] -c cpu.prof -m mem.prof +``` + +View profile on cmdline +``` +go tool pprof cpu.prof +``` + +View profile in browser (requires [graphviz](https://graphviz.org/download/)) +``` +go tool pprof -http :9000 cpu.prof +``` + +## Results + +To add your own results to this file, create an entry in the [Machine Info](#machine-info) table with +a unique identifer and basic info about the computer where you are running the benchmarks. +On linux you can get most of the info from `lshw -short`. By default, benchmark stats will use the local username +from environment variables or the os package if `MACHINE_NAME` env is not provided. + +Then run all benchmarks: +``` +# Optional +export MACHINE_NAME= +export PIXEL_VERSION= + +go run main.go bench run --all +``` + +### Machine Info + +| Machine | OS/Distro | CPU | Memory | GPU | +|--------------------|---------------------|-------------------------------|--------------------|----------------| +| bhperry-wsl | Linux Ubuntu 20.04 | Intel i7-8086K @ 4.00GHz | 8GiB | RTX 2080 | +| bhperry-win10 | Windows 10 | Intel i7-8086K @ 4.00GHz | 16GiB | RTX 2080 | + +### Stats + +| Machine | Pixel | Benchmark | Duration | Frames | FPS Avg | FPS Min | FPS Max | FPS Stdev | +|--------------------|--------|------------------------------|----------|--------|---------|---------|---------|-----------| +| bhperry-wsl | v2.2.1 | imdraw-moving | 30s | 2214 | 73.79 | 68 | 76 | 1.77 | +| bhperry-wsl | v2.2.1 | imdraw-moving-batched | 30s | 5658 | 188.57 | 166 | 195 | 5.86 | +| bhperry-wsl | v2.2.1 | imdraw-static | 30s | 2355 | 78.5 | 72 | 81 | 1.89 | +| bhperry-wsl | v2.2.1 | imdraw-static-batched | 30.01s | 6171 | 205.64 | 168 | 212 | 9.62 | +| bhperry-wsl | v2.2.1 | sprite-moving | 30.03s | 1451 | 48.32 | 45 | 50 | 1.25 | +| bhperry-wsl | v2.2.1 | sprite-moving-batched | 30.01s | 4085 | 136.12 | 127 | 142 | 3.17 | +| bhperry-wsl | v2.2.1 | sprite-static | 30.01s | 1518 | 50.59 | 47 | 52 | 1.45 | +| bhperry-wsl | v2.2.1 | sprite-static-batched | 30.01s | 5318 | 177.2 | 159 | 182 | 6.01 | +| bhperry-win10 | v2.2.1 | imdraw-moving | 30.03s | 1430 | 47.61 | 22 | 50 | 5.85 | +| bhperry-win10 | v2.2.1 | imdraw-moving-batched | 30s | 52017 | 1733.9 | 1635 | 1915 | 43.92 | +| bhperry-win10 | v2.2.1 | imdraw-static | 30.02s | 1569 | 52.27 | 51 | 53 | 0.64 | +| bhperry-win10 | v2.2.1 | imdraw-static-batched | 30.01s | 1517 | 50.55 | 21 | 53 | 6.62 | +| bhperry-win10 | v2.2.1 | sprite-moving | 30.03s | 1148 | 38.23 | 35 | 39 | 0.9 | +| bhperry-win10 | v2.2.1 | sprite-moving-batched | 30s | 39085 | 1302.79 | 1205 | 1329 | 23.93 | +| bhperry-win10 | v2.2.1 | sprite-static | 30.04s | 1218 | 40.54 | 38 | 42 | 0.88 | +| bhperry-win10 | v2.2.1 | sprite-static-batched | 30s | 40570 | 1352.29 | 1245 | 1380 | 26.04 | diff --git a/tools/benchmark/benchmark.go b/tools/benchmark/benchmark.go new file mode 100644 index 0000000..d00c4c1 --- /dev/null +++ b/tools/benchmark/benchmark.go @@ -0,0 +1,141 @@ +package benchmark + +import ( + "fmt" + "slices" + "time" + + "github.com/gopxl/pixel/v2" + "github.com/gopxl/pixel/v2/backends/opengl" +) + +var Benchmarks = &Registry{benchmarks: map[string]Config{}} + +// Config defines how to run a given benchmark, along with metadata describing it +type Config struct { + Name string + Description string + + // New returns the benchmark to be executed + New func(win *opengl.Window) (Benchmark, error) + // Duration sets the maximum duration to run the benchmark + Duration time.Duration + // WindowConfig defines the input parameters to the benchmark's window + WindowConfig opengl.WindowConfig +} + +// Run executes the benchmark and calculates statistics about its performance +func (c Config) Run() (*Stats, error) { + fmt.Printf("Running benchmark %s\n", c.Name) + + windowConfig := c.WindowConfig + title := windowConfig.Title + if title == "" { + title = c.Name + } + windowConfig.Title = fmt.Sprintf("%s | FPS -", title) + + if windowConfig.Bounds.Empty() { + windowConfig.Bounds = pixel.R(0, 0, 1024, 1024) + } + if windowConfig.Position.Eq(pixel.ZV) { + windowConfig.Position = pixel.V(50, 50) + } + + duration := c.Duration + if duration == 0 { + duration = 10 * time.Second + } + + win, err := opengl.NewWindow(windowConfig) + if err != nil { + return nil, err + } + defer win.Destroy() + + benchmark, err := c.New(win) + if err != nil { + return nil, err + } + + frame := 0 + frameSeconds := make([]int, 0) + prevFrameCount := 0 + second := time.NewTicker(time.Second) + done := time.NewTicker(duration) + start := time.Now() + last := start +loop: + for frame = 0; !win.Closed(); frame++ { + now := time.Now() + benchmark.Step(win, now.Sub(last).Seconds()) + last = now + win.Update() + + select { + case <-second.C: + frameSeconds = append(frameSeconds, frame) + win.SetTitle(fmt.Sprintf("%s | FPS %v", title, frame-prevFrameCount)) + prevFrameCount = frame + case <-done.C: + break loop + default: + } + } + stats := NewStats(c.Name, time.Since(start), frame, frameSeconds) + + if win.Closed() { + return nil, fmt.Errorf("window closed early") + } + + return stats, err +} + +// Benchmark provides hooks into the stages of a window's lifecycle +type Benchmark interface { + Step(win *opengl.Window, delta float64) +} + +// Registry is a collection of benchmark configs +type Registry struct { + benchmarks map[string]Config +} + +// List returns a copy of all registered benchmark configs +func (r *Registry) List() []Config { + configs := make([]Config, len(r.benchmarks)) + for i, name := range r.ListNames() { + configs[i] = r.benchmarks[name] + i++ + } + return configs +} + +// ListNames returns a sorted list of all registered benchmark names +func (r *Registry) ListNames() []string { + names := make([]string, len(r.benchmarks)) + i := 0 + for name := range r.benchmarks { + names[i] = name + i++ + } + slices.Sort(names) + return names +} + +// Add a benchmark config to the registry +func (r *Registry) Add(configs ...Config) { + for _, config := range configs { + r.benchmarks[config.Name] = config + } +} + +// Get a benchmark config by name +func (r *Registry) Get(name string) (Config, error) { + config, ok := r.benchmarks[name] + if !ok { + return config, fmt.Errorf("unknown benchmark %s", name) + } + + return config, nil +} diff --git a/tools/benchmark/imdraw_bench.go b/tools/benchmark/imdraw_bench.go new file mode 100644 index 0000000..c2b6387 --- /dev/null +++ b/tools/benchmark/imdraw_bench.go @@ -0,0 +1,190 @@ +package benchmark + +import ( + "math" + "time" + + "github.com/gopxl/pixel/v2" + "github.com/gopxl/pixel/v2/backends/opengl" + "github.com/gopxl/pixel/v2/ext/imdraw" +) + +var ( + backgroundColor = pixel.RGB(0, 0, 0) +) + +func init() { + Benchmarks.Add( + Config{ + Name: "imdraw-static", + Description: "Stationary RGB triangles in a grid", + New: newStaticTriangles, + Duration: 30 * time.Second, + }, + Config{ + Name: "imdraw-static-batched", + Description: "Stationary RGB triangles in a grid with batched draw", + New: newStaticTrianglesBatched, + Duration: 30 * time.Second, + }, + Config{ + Name: "imdraw-moving", + Description: "Columns of RGB triangles moving in opposite directions", + New: newMovingTriangles, + Duration: 30 * time.Second, + }, + Config{ + Name: "imdraw-moving-batched", + Description: "Columns of RGB triangles moving in opposite directions with batched draw", + New: newMovingTrianglesBatched, + Duration: 30 * time.Second, + }, + ) +} + +func newStaticTriangles(win *opengl.Window) (Benchmark, error) { + bounds := win.Bounds() + width := bounds.W() + height := bounds.H() + rows, cols := 32, 32 + cell := gridCell(width, height, rows, cols) + benchmark := &staticTriangles{ + imd: tri(cell), + rows: rows, + cols: cols, + cell: cell, + } + return benchmark, nil +} + +func newStaticTrianglesBatched(win *opengl.Window) (Benchmark, error) { + benchmark, err := newStaticTriangles(win) + if err != nil { + return nil, err + } + st := benchmark.(*staticTriangles) + st.target = pixel.NewBatch(&pixel.TrianglesData{}, nil) + return st, nil +} + +type staticTriangles struct { + imd *imdraw.IMDraw + batch *pixel.Batch + target pixel.BasicTarget + rows, cols int + cell pixel.Vec +} + +func (st *staticTriangles) Step(win *opengl.Window, delta float64) { + win.Clear(backgroundColor) + + var target pixel.BasicTarget + if st.batch != nil { + st.batch.Clear() + target = st.batch + } else { + target = win + } + + for i := 0; i < st.cols; i++ { + for j := 0; j < st.rows; j++ { + pos := pixel.V(float64(i)*st.cell.X, float64(j)*st.cell.Y) + target.SetMatrix(pixel.IM.Moved(pos)) + st.imd.Draw(target) + } + } + + if st.batch != nil { + st.batch.Draw(win) + } +} + +func newMovingTriangles(win *opengl.Window) (Benchmark, error) { + bounds := win.Bounds() + width := bounds.W() + height := bounds.H() + rows, cols := 32, 32 + cell := gridCell(width, height, rows, cols) + benchmark := &movingTriangles{ + imd: tri(cell), + rows: rows, + cols: cols, + cell: cell, + } + return benchmark, nil +} + +func newMovingTrianglesBatched(win *opengl.Window) (Benchmark, error) { + benchmark, err := newMovingTriangles(win) + if err != nil { + return nil, err + } + + mt := benchmark.(*movingTriangles) + mt.batch = pixel.NewBatch(&pixel.TrianglesData{}, nil) + return mt, nil +} + +type movingTriangles struct { + imd *imdraw.IMDraw + batch *pixel.Batch + rows, cols int + cell pixel.Vec + yOffset float64 +} + +func (mt *movingTriangles) Step(win *opengl.Window, delta float64) { + win.Clear(backgroundColor) + + var target pixel.BasicTarget + if mt.batch != nil { + mt.batch.Clear() + target = mt.batch + } else { + target = win + } + + mt.yOffset += mt.cell.Y * delta * 3 + if mt.yOffset >= mt.cell.Y { + mt.yOffset = 0 + } + + for i := 0; i < mt.cols; i++ { + columnOffset := -mt.cell.Y + if i%2 == 0 { + columnOffset += mt.yOffset + } else { + columnOffset -= mt.yOffset + } + + for j := 0; j < mt.rows+2; j++ { + pos := pixel.V(float64(i)*mt.cell.X, (float64(j)*mt.cell.Y)+columnOffset) + matrix := pixel.IM.Moved(pos) + if i%2 == 1 { + matrix = matrix.Rotated(pos.Add(pixel.V(mt.cell.X/2, mt.cell.Y/2)), math.Pi) + } + target.SetMatrix(matrix) + mt.imd.Draw(target) + } + } + + if mt.batch != nil { + mt.batch.Draw(win) + } +} + +func tri(cell pixel.Vec) *imdraw.IMDraw { + imd := imdraw.New(nil) + imd.Color = pixel.RGB(1, 0, 0) + imd.Push(pixel.V(0, 0)) + imd.Color = pixel.RGB(0, 1, 0) + imd.Push(pixel.V(cell.X, 0)) + imd.Color = pixel.RGB(0, 0, 1) + imd.Push(pixel.V(cell.X/2, cell.Y)) + imd.Polygon(0) + return imd +} + +func gridCell(width, height float64, rows, cols int) (cell pixel.Vec) { + return pixel.V(width/float64(cols), height/float64(rows)) +} diff --git a/tools/benchmark/sprite_bench.go b/tools/benchmark/sprite_bench.go new file mode 100644 index 0000000..14eccfc --- /dev/null +++ b/tools/benchmark/sprite_bench.go @@ -0,0 +1,222 @@ +package benchmark + +import ( + "image" + "image/png" + "os" + "path" + "path/filepath" + "runtime" + "time" + + "github.com/gopxl/pixel/v2" + "github.com/gopxl/pixel/v2/backends/opengl" +) + +var ( + basepath string + logoPath = "logo/LOGOTYPE-HORIZONTAL-BLUE2.png" + logoFrame = pixel.R(98, 44, 234, 180) +) + +func init() { + _, b, _, _ := runtime.Caller(0) + basepath = filepath.ToSlash(filepath.Dir(filepath.Dir(filepath.Dir(b)))) + logoPath = path.Join(basepath, logoPath) + + Benchmarks.Add( + Config{ + Name: "sprite-moving", + Description: "Columns of sprites moving in opposite directions", + New: newSpriteMoving, + Duration: 30 * time.Second, + }, + Config{ + Name: "sprite-moving-batched", + Description: "Columns of sprites moving in opposite directions with batched draw", + New: newSpriteMovingBatched, + Duration: 30 * time.Second, + }, + Config{ + Name: "sprite-static", + Description: "Draw a sprite to the window in a grid", + New: newSpriteStatic, + Duration: 30 * time.Second, + }, + Config{ + Name: "sprite-static-batched", + Description: "Draw a sprite to the window in a grid with batched draw", + New: newSpriteStaticBatched, + Duration: 30 * time.Second, + }, + ) +} + +func newSpriteStatic(win *opengl.Window) (Benchmark, error) { + sprite, err := loadSprite(logoPath, logoFrame) + if err != nil { + return nil, err + } + + bounds := win.Bounds() + width := bounds.W() + height := bounds.H() + rows, cols := 32, 32 + + benchmark := &spriteStatic{ + sprite: sprite, + rows: rows, + cols: rows, + cell: gridCell(width, height, rows, cols), + } + return benchmark, nil +} + +func newSpriteStaticBatched(win *opengl.Window) (Benchmark, error) { + benchmark, err := newSpriteStatic(win) + if err != nil { + return nil, err + } + ss := benchmark.(*spriteStatic) + ss.batch = pixel.NewBatch(&pixel.TrianglesData{}, ss.sprite.Picture()) + return ss, nil +} + +type spriteStatic struct { + sprite *pixel.Sprite + rows, cols int + cell pixel.Vec + batch *pixel.Batch +} + +func (ss *spriteStatic) Step(win *opengl.Window, delta float64) { + win.Clear(backgroundColor) + var target pixel.Target + if ss.batch != nil { + ss.batch.Clear() + target = ss.batch + } else { + target = win + } + spriteGrid(ss.sprite, target, ss.rows, ss.cols, ss.cell) + if ss.batch != nil { + ss.batch.Draw(win) + } +} + +func newSpriteMoving(win *opengl.Window) (Benchmark, error) { + sprite, err := loadSprite(logoPath, logoFrame) + if err != nil { + return nil, err + } + bounds := win.Bounds() + width := bounds.W() + height := bounds.H() + rows, cols := 32, 32 + benchmark := &spriteMoving{ + sprite: sprite, + rows: rows, + cols: cols, + cell: gridCell(width, height, rows, cols), + } + return benchmark, nil +} + +func newSpriteMovingBatched(win *opengl.Window) (Benchmark, error) { + benchmark, err := newSpriteMoving(win) + if err != nil { + return nil, err + } + sm := benchmark.(*spriteMoving) + sm.batch = pixel.NewBatch(&pixel.TrianglesData{}, sm.sprite.Picture()) + return sm, nil +} + +type spriteMoving struct { + sprite *pixel.Sprite + batch *pixel.Batch + rows, cols int + cell pixel.Vec + yOffset float64 +} + +func (sm *spriteMoving) Step(win *opengl.Window, delta float64) { + win.Clear(backgroundColor) + var target pixel.Target + if sm.batch != nil { + sm.batch.Clear() + target = sm.batch + } else { + target = win + } + + sm.yOffset += sm.cell.Y * delta * 3 + if sm.yOffset >= sm.cell.Y { + sm.yOffset = 0 + } + + spriteGridMoving(sm.sprite, target, sm.rows, sm.cols, sm.cell, sm.yOffset) + if sm.batch != nil { + sm.batch.Draw(win) + } +} + +func spriteGrid(sprite *pixel.Sprite, target pixel.Target, rows, cols int, cell pixel.Vec) { + spriteBounds := sprite.Frame().Bounds() + spriteWidth := spriteBounds.W() + spriteHeight := spriteBounds.H() + matrix := pixel.IM.ScaledXY(pixel.ZV, pixel.V(cell.X/spriteWidth, cell.Y/spriteHeight)) + offset := pixel.V(cell.X/2, cell.Y/2) + for i := 0; i < cols; i++ { + for j := 0; j < rows; j++ { + pos := pixel.V(float64(i)*cell.X, float64(j)*cell.Y).Add(offset) + sprite.Draw(target, matrix.Moved(pos)) + } + } +} + +func spriteGridMoving(sprite *pixel.Sprite, target pixel.Target, rows, cols int, cell pixel.Vec, yOffset float64) { + spriteBounds := sprite.Frame().Bounds() + spriteWidth := spriteBounds.W() + spriteHeight := spriteBounds.H() + matrix := pixel.IM.ScaledXY(pixel.ZV, pixel.V(cell.X/spriteWidth, cell.Y/spriteHeight)) + offset := pixel.V(cell.X/2, cell.Y/2) + for i := 0; i < cols; i++ { + columnOffset := -cell.Y + if i%2 == 0 { + columnOffset += yOffset + } else { + columnOffset -= yOffset + } + + for j := 0; j < rows+2; j++ { + pos := pixel.V(float64(i)*cell.X, (float64(j)*cell.Y)+columnOffset).Add(offset) + sprite.Draw(target, matrix.Moved(pos)) + } + } +} + +func loadSprite(file string, frame pixel.Rect) (sprite *pixel.Sprite, err error) { + image, err := loadPng(file) + if err != nil { + return nil, err + } + + pic := pixel.PictureDataFromImage(image) + if frame.Empty() { + frame = pic.Bounds() + } + sprite = pixel.NewSprite(pic, frame) + return sprite, nil +} + +func loadPng(file string) (i image.Image, err error) { + f, err := os.Open(file) + if err != nil { + return + } + defer f.Close() + + i, err = png.Decode(f) + return +} diff --git a/tools/benchmark/stats.go b/tools/benchmark/stats.go new file mode 100644 index 0000000..a2480c8 --- /dev/null +++ b/tools/benchmark/stats.go @@ -0,0 +1,215 @@ +package benchmark + +import ( + "encoding/json" + "fmt" + "math" + "os" + "os/user" + "runtime/debug" + "slices" + "strings" + "time" + + "github.com/olekukonko/tablewriter" +) + +var ( + machineName, pixelVersion string +) + +func init() { + machineName = getMachineName() + pixelVersion = getPixelVersion() +} + +// NewStats calculates statistics about a benchmark run +func NewStats(name string, duration time.Duration, frames int, frameSeconds []int) *Stats { + stats := &Stats{ + Name: name, + Frames: frames, + Duration: duration, + Machine: machineName, + PixelVersion: pixelVersion, + } + + milliseconds := stats.Duration.Milliseconds() + if milliseconds > 0 { + stats.AvgFPS = roundFloat(1000*float64(frames)/float64(milliseconds), 2) + } + + fps := make([]float64, 0, len(frameSeconds)) + for i, frame := range frameSeconds { + if i == 0 { + fps = append(fps, float64(frame)) + } else { + fps = append(fps, float64(frame-frameSeconds[i-1])) + } + } + if len(fps) > 0 { + stats.MinFPS = slices.Min(fps) + stats.MaxFPS = slices.Max(fps) + stats.StdevFPS = standardDeviation(fps) + } else { + // 1s or less test. Use average as a stand-in. + stats.MinFPS = math.Floor(stats.AvgFPS) + stats.MaxFPS = math.Ceil(stats.AvgFPS) + } + + return stats +} + +// Stats stores data about the performance of a benchmark run +type Stats struct { + Name string `json:"name"` + AvgFPS float64 `json:"avgFPS"` + MinFPS float64 `json:"minFPS"` + MaxFPS float64 `json:"maxFPS"` + StdevFPS float64 `json:"stdevFPS"` + + Frames int `json:"frames"` + Duration time.Duration `json:"duration"` + + Machine string `json:"machine"` + PixelVersion string `json:"pixelVersion"` +} + +// Print stats to stdout in a human-readable format +func (s *Stats) Print() { + StatsCollection{s}.Print() +} + +// StatsCollection holds stats from multiple benchmark runs +type StatsCollection []*Stats + +func (sc StatsCollection) Print() { + data := make([][]string, len(sc)) + for i, stats := range sc { + data[i] = []string{ + stats.Machine, + stats.PixelVersion, + stats.Name, + roundDuration(stats.Duration, 2).String(), + toString(stats.Frames), + toString(stats.AvgFPS), + toString(stats.MinFPS), + toString(stats.MaxFPS), + toString(stats.StdevFPS), + } + } + + table := tablewriter.NewWriter(os.Stdout) + headers := []string{"Machine", "Pixel", "Benchmark", "Duration", "Frames", "FPS Avg", "FPS Min", "FPS Max", "FPS Stdev"} + widths := map[string]int{ + "Machine": 18, + "Pixel": 6, + "Benchmark": 28, + } + for i, header := range headers { + minWidth := widths[header] + if minWidth == 0 { + minWidth = 6 + } + table.SetColMinWidth(i, minWidth) + } + table.SetHeader(headers) + table.SetAutoFormatHeaders(false) + table.SetAlignment(tablewriter.ALIGN_LEFT) + table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) + table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false}) + table.SetCenterSeparator("|") + table.AppendBulk(data) + table.Render() +} + +// Dump writes a JSON file of all stored statistics to the given path +func (sc StatsCollection) Dump(path string) error { + bytes, err := json.Marshal(sc) + if err != nil { + return err + } + if err := os.WriteFile(path, bytes, 0666); err != nil { + return err + } + return nil +} + +// roundFloat rounds the value to the given number of decimal places +func roundFloat(val float64, precision uint) float64 { + ratio := math.Pow(10, float64(precision)) + return math.Round(val*ratio) / ratio +} + +// roundDuration rounds the duration to the given number of decimal places based on the unit +func roundDuration(duration time.Duration, precision uint) time.Duration { + durationRounding := time.Duration(math.Pow(10, float64(precision))) + switch { + case duration > time.Second: + return duration.Round(time.Second / durationRounding) + case duration > time.Millisecond: + return duration.Round(time.Millisecond / durationRounding) + case duration > time.Microsecond: + return duration.Round(time.Microsecond / durationRounding) + default: + return duration + } +} + +func toString(val any) string { + switch v := val.(type) { + case float64: + return fmt.Sprintf("%v", roundFloat(v, 2)) + case float32: + return fmt.Sprintf("%v", roundFloat(float64(v), 2)) + default: + return fmt.Sprintf("%v", v) + } +} + +// standardDeviation calulates the variation of the given values relative to the average +func standardDeviation(values []float64) float64 { + var sum, avg, stdev float64 + for _, val := range values { + sum += val + } + count := float64(len(values)) + avg = sum / count + + for _, val := range values { + stdev += math.Pow(val-avg, 2) + } + stdev = math.Sqrt(stdev / count) + return stdev +} + +func getMachineName() string { + envs := []string{"MACHINE_NAME", "USER", "USERNAME"} + var name string + for _, env := range envs { + name = os.Getenv(env) + if name != "" { + return name + } + } + if u, err := user.Current(); err == nil { + return u.Username + } + return "unknown" +} + +func getPixelVersion() string { + ver := os.Getenv("PIXEL_VERSION") + if ver != "" { + return ver + } + + bi, ok := debug.ReadBuildInfo() + if ok { + for _, dep := range bi.Deps { + if dep.Path == "github.com/gopxl/pixel/v2" { + return strings.Split(dep.Version, "-")[0] + } + } + } + return "x.y.z" +} diff --git a/tools/cmd/bench.go b/tools/cmd/bench.go new file mode 100644 index 0000000..2d50e6d --- /dev/null +++ b/tools/cmd/bench.go @@ -0,0 +1,166 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "runtime" + "runtime/pprof" + "text/tabwriter" + "time" + + "github.com/gopxl/pixel/tools/benchmark" + "github.com/gopxl/pixel/v2/backends/opengl" + "github.com/spf13/cobra" +) + +var ( + benchRunAll bool + benchRunOutput, + benchRunCpuprofile, + benchRunMemprofile string + benchRunDuration time.Duration + + benchStatsInput string +) + +func NewBenchCmd() *cobra.Command { + bench := &cobra.Command{ + Use: "bench", + Short: "Benchmark the pixel library", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, + } + bench.AddCommand(NewBenchLsCmd(), NewBenchRunCmd(), NewBenchStatsCmd()) + return bench +} + +func NewBenchRunCmd() *cobra.Command { + run := &cobra.Command{ + Use: "run [names...] [opts]", + Short: "Run one or more benchmark tests", + RunE: func(cmd *cobra.Command, args []string) error { + if benchRunAll { + args = benchmark.Benchmarks.ListNames() + } else if len(args) == 0 { + return fmt.Errorf("requires at least one benchmark") + } + cmd.SilenceUsage = true + + // Start CPU profile + if benchRunCpuprofile != "" { + f, err := os.Create(benchRunCpuprofile) + if err != nil { + return fmt.Errorf("could not create CPU profile: %v", err) + } + defer f.Close() + if err := pprof.StartCPUProfile(f); err != nil { + return fmt.Errorf("could not start CPU profile: %v", err) + } + defer pprof.StopCPUProfile() + } + + // Run benchmark(s) + benchStats := make(benchmark.StatsCollection, len(args)) + var err error + run := func() { + var config benchmark.Config + for i, name := range args { + config, err = benchmark.Benchmarks.Get(name) + if err != nil { + return + } + + if benchRunDuration != 0 { + config.Duration = benchRunDuration + } + + var stats *benchmark.Stats + stats, err = config.Run() + if err != nil { + return + } + benchStats[i] = stats + } + } + + opengl.Run(run) + if err != nil { + return err + } + fmt.Println() + benchStats.Print() + + // Dump memory profile + if benchRunMemprofile != "" { + f, err := os.Create(benchRunMemprofile) + if err != nil { + return fmt.Errorf("could not create memory profile: %v", err) + } + defer f.Close() + runtime.GC() // get up-to-date statistics + if err := pprof.WriteHeapProfile(f); err != nil { + return fmt.Errorf("could not write memory profile: %v", err) + } + } + + // Dump stats + if benchRunOutput != "" { + err := benchStats.Dump(benchRunOutput) + if err != nil { + return err + } + } + return nil + }, + } + + run.Flags().BoolVarP(&benchRunAll, "all", "a", false, "Run all registered benchmarks") + run.Flags().StringVarP(&benchRunOutput, "output", "o", "", "Output path for statistics file") + run.Flags().DurationVarP(&benchRunDuration, "duration", "d", 0, "Override duration for benchmark runs") + run.Flags().StringVarP(&benchRunCpuprofile, "cpuprofile", "c", "", "CPU profiling file") + run.Flags().StringVarP(&benchRunMemprofile, "memprofile", "m", "", "Memory profiling file") + return run +} + +func NewBenchLsCmd() *cobra.Command { + return &cobra.Command{ + Use: "ls", + Short: "List available benchmarks", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + w := tabwriter.NewWriter(os.Stdout, 1, 4, 8, ' ', 0) + for _, config := range benchmark.Benchmarks.List() { + fmt.Fprintf(w, "%s\t%s\n", config.Name, config.Description) + } + w.Flush() + }, + } +} + +func NewBenchStatsCmd() *cobra.Command { + stats := &cobra.Command{ + Use: "stats -i [path/to/stats.json]", + Short: "Pretty print the contents of a stats file", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + bytes, err := os.ReadFile(benchStatsInput) + if err != nil { + return err + } + + var benchStats benchmark.StatsCollection + if err := json.Unmarshal(bytes, &benchStats); err != nil { + return err + } + benchStats.Print() + + return nil + }, + } + + stats.Flags().StringVarP(&benchStatsInput, "input", "i", "", "Input path for statistics file") + stats.MarkFlagRequired("input") + return stats +} diff --git a/tools/cmd/root.go b/tools/cmd/root.go new file mode 100644 index 0000000..a536cd4 --- /dev/null +++ b/tools/cmd/root.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +func NewRootCmd() *cobra.Command { + root := &cobra.Command{ + Use: "pixeltools", + Short: "Pixel tools provide benchmarking and validation tools for developing the pixel library", + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, + } + root.AddCommand(NewBenchCmd()) + return root +} + +func Execute() { + if err := NewRootCmd().Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/tools/go.mod b/tools/go.mod new file mode 100644 index 0000000..e86ca7f --- /dev/null +++ b/tools/go.mod @@ -0,0 +1,24 @@ +module github.com/gopxl/pixel/tools + +go 1.21 + +require ( + github.com/gopxl/pixel/v2 v2.2.1-local + github.com/olekukonko/tablewriter v0.0.5 + github.com/spf13/cobra v1.8.1 +) + +replace github.com/gopxl/pixel/v2 v2.2.1-local => ../ + +require ( + github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect + github.com/go-gl/mathgl v1.1.0 // indirect + github.com/gopxl/glhf/v2 v2.0.0 // indirect + github.com/gopxl/mainthread/v2 v2.1.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-runewidth v0.0.9 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/image v0.19.0 // indirect +) diff --git a/tools/go.sum b/tools/go.sum new file mode 100644 index 0000000..5273201 --- /dev/null +++ b/tools/go.sum @@ -0,0 +1,37 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/mathgl v1.1.0 h1:0lzZ+rntPX3/oGrDzYGdowSLC2ky8Osirvf5uAwfIEA= +github.com/go-gl/mathgl v1.1.0/go.mod h1:yhpkQzEiH9yPyxDUGzkmgScbaBVlhC06qodikEM0ZwQ= +github.com/gopxl/glhf/v2 v2.0.0 h1:SJtNy+TXuTBRjMersNx722VDJ0XHIooMH2+7+99LPIc= +github.com/gopxl/glhf/v2 v2.0.0/go.mod h1:InKwj5OoVdOAkpzsS0ILwpB+RrWBLw1i7aFefiGmrp8= +github.com/gopxl/mainthread/v2 v2.1.1 h1:S7jIvQZth9s2k8qFePOxtEgtZLzW/Yjykum2mscGr0o= +github.com/gopxl/mainthread/v2 v2.1.1/go.mod h1:RLdqSRamocAGPzK9P4HsZf+WXL5bfHHtX78O6GkKaUw= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/image v0.0.0-20190321063152-3fc05d484e9f/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.19.0 h1:D9FX4QWkLfkeqaC62SonffIIuYdOk/UE2XKUBgRIBIQ= +golang.org/x/image v0.19.0/go.mod h1:y0zrRqlQRWQ5PXaYCOMLTW2fpsxZ8Qh9I/ohnInJEys= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/main.go b/tools/main.go new file mode 100644 index 0000000..3694170 --- /dev/null +++ b/tools/main.go @@ -0,0 +1,9 @@ +package main + +import ( + "github.com/gopxl/pixel/tools/cmd" +) + +func main() { + cmd.Execute() +}