Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
Winston Ho committed Sep 24, 2022
0 parents commit 04babf7
Show file tree
Hide file tree
Showing 5 changed files with 331 additions and 0 deletions.
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Sokutei

A command-line benchmarking tool written in Go.

# Installation

```sh
go install github.com/violentestpen/sokutei@latest
```

# Inspired by

- [Hyperfine](https://github.com/sharkdp/hyperfine)
- [bench](https://github.com/Gabriella439/bench)
9 changes: 9 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module github.com/violenttestpen/sokutei

go 1.16

require (
github.com/briandowns/spinner v1.19.0 // indirect
github.com/fatih/color v1.13.0
golang.org/x/term v0.0.0-20220919170432-7a66f970e087
)
20 changes: 20 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
github.com/briandowns/spinner v1.19.0 h1:s8aq38H+Qju89yhp89b4iIiMzMm8YN3p6vGpwyh/a8E=
github.com/briandowns/spinner v1.19.0/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20220919170432-7a66f970e087 h1:tPwmk4vmvVCMdr98VgL4JH+qZxPL8fqlUOHnyOM8N3w=
golang.org/x/term v0.0.0-20220919170432-7a66f970e087/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
203 changes: 203 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
package main

import (
"context"
"errors"
"flag"
"fmt"
"math"
"os/exec"
"runtime"
"sort"
"time"

"github.com/fatih/color"
)

// https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-getprocesstimes?redirectedfrom=MSDN

const (
progressDoneRune = "█"
progressPendingRune = "▒"
)

var (
noShell bool
runs int64
shell string
warmup int64

setupCmd string

noColor bool
)

type benchmarkResult struct {
cmd string

mean int64
stdev float64
min int64
max int64

denominator float64
unit string
}

func init() {
switch runtime.GOOS {
case "windows":
shell = "cmd.exe"
default:
shell = "/bin/sh"
}
}

func runSetup(ctx context.Context, cmdToSetup string) error {
cmdParts := list2Cmdline(cmdToSetup)
if len(cmdParts) == 0 {
return errors.New("empty command string")
}
return exec.CommandContext(ctx, cmdParts[0], cmdParts[1:]...).Run()
}

func runBenchmark(ctx context.Context, cmdToBenchmark string) (*benchmarkResult, error) {
cmdParts := list2Cmdline(cmdToBenchmark)
if len(cmdParts) == 0 {
return nil, errors.New("empty command string")
}

fmt.Print("Performing warmup runs")
for i := int64(0); i < warmup; i++ {
cmd := exec.CommandContext(ctx, cmdParts[0], cmdParts[1:]...)
if err := cmd.Run(); err != nil {
return nil, err
}
}
clearCurrentTerminalLine(color.Output)

var currentEstimate int64
var currentDenominator float64
var currentUnitEstimate string
elapsedRuns := make([]int64, runs)

fmt.Print("Initial time measurement")
for i := int64(0); i < runs; i++ {
cmd := exec.CommandContext(ctx, cmdParts[0], cmdParts[1:]...)

startTime := time.Now()
err := cmd.Run()
elapsedRuns[i] = int64(time.Since(startTime))

if err != nil {
return nil, err
}

// Calculate current estimate and ETA
currentEstimate = (currentEstimate*i + elapsedRuns[i]) / (i + 1)
currentDenominator, currentUnitEstimate = getMeasurementMetrics(currentEstimate)
etaEstimate := time.Duration(currentEstimate * (runs - i))

clearCurrentTerminalLine(color.Output)
line := fmt.Sprintf("Current estimate: %s ",
color.GreenString("%.2f %s", float64(currentEstimate)/currentDenominator, currentUnitEstimate))
printProgressLine(line, float64(i+1)/float64(runs), etaEstimate)
}
clearCurrentTerminalLine(color.Output)

// Calculate mean, min and max timings
minElapsed := int64(math.MaxInt64)
maxElapsed := int64(math.MinInt64)
var totalElapsed int64
for _, elapsed := range elapsedRuns {
totalElapsed += int64(elapsed)
if elapsed < minElapsed {
minElapsed = elapsed
}
if elapsed > maxElapsed {
maxElapsed = elapsed
}
}

result := &benchmarkResult{
cmd: cmdToBenchmark,
mean: totalElapsed / runs,
min: minElapsed,
max: maxElapsed,
}

// Calculate standard deviation and get appropriate meansurement unit
result.stdev = stdev(elapsedRuns, result.mean)
result.denominator, result.unit = getMeasurementMetrics(result.mean)
return result, nil
}

func main() {
flag.BoolVar(&noShell, "N", false, "Run benchmarks without an intermediate shell")
flag.Int64Var(&runs, "runs", 10, "Number of rounds to warmup")
flag.Int64Var(&warmup, "warmup", 0, "Number of rounds to warmup")
flag.StringVar(&setupCmd, "setup", "", "Command to run before all benchmarks")
flag.StringVar(&shell, "S", shell, "The intermediate shell to run benchmarks in")
flag.BoolVar(&noColor, "no-color", false, "Disable coloured output")
flag.Parse()
cmds := flag.Args()

color.NoColor = noColor
ctx := context.Background()

if setupCmd != "" {
if err := runSetup(ctx, setupCmd); err != nil {
fmt.Println("An error occurred during setup:", err)
return
}
}

if !noShell && shell != "" {
// Measuring shell spawning time
}

results := make([]*benchmarkResult, 0, len(cmds))
for i, cmd := range cmds {
fmt.Printf("Benchmark #%d: %s\n", i+1, cmd)
result, err := runBenchmark(ctx, cmd)
if err != nil {
fmt.Println("An error occurred during benchmark:", err)
} else {
fmt.Fprintf(color.Output, " Time (%s ± %s):\t%s ± %s\n",
color.GreenString("mean"),
color.GreenString("σ"),
color.GreenString("%.2f %s", float64(result.mean)/result.denominator, result.unit),
color.GreenString("%.2f %s", result.stdev/result.denominator, result.unit))
fmt.Fprintf(color.Output, " Range (%s … %s):\t%s … %s\t%s\n",
color.CyanString("min"),
color.RedString("max"),
color.CyanString("%.2f %s", float64(result.min)/result.denominator, result.unit),
color.RedString("%.2f %s", float64(result.max)/result.denominator, result.unit),
color.HiBlackString("%d runs", runs))
fmt.Println()

results = append(results, result)
}
}

if len(results) > 1 {
fmt.Println("Summary")

sort.SliceStable(results, func(i, j int) bool { return results[i].mean < results[j].mean })
var fastestResult *benchmarkResult
for _, result := range results {
if fastestResult == nil {
fastestResult = result
fmt.Fprintf(color.Output, " '%s' ran\n", color.CyanString(fastestResult.cmd))
} else {
meanMultiplier := float64(result.mean) / float64(fastestResult.mean)
posStdevMultiplier := (float64(result.mean)+result.stdev)/(float64(fastestResult.mean)+fastestResult.stdev) - meanMultiplier
negStdevMultiplier := meanMultiplier - (float64(result.mean)-result.stdev)/(float64(fastestResult.mean)-fastestResult.stdev)
fmt.Fprintf(color.Output, " %s ± %s times faster than '%s'\n",
color.GreenString("%.2f", meanMultiplier),
color.GreenString("%.2f", math.Abs(posStdevMultiplier)+math.Abs(negStdevMultiplier)),
color.RedString(result.cmd))
}
}
}
}
85 changes: 85 additions & 0 deletions util.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package main

import (
"fmt"
"io"
"math"
"os"
"strings"
"time"

"github.com/fatih/color"
"golang.org/x/term"
)

var denominators = []int64{int64(time.Hour), int64(time.Minute), int64(time.Second), int64(time.Millisecond), int64(time.Microsecond), int64(time.Nanosecond)}
var units = []string{"h", "m", "s", "ms", "µs", "ns"}

// Translate a sequence of arguments into a command line string, using the same rules as the MS C runtime:
// 1) Arguments are delimited by white space, which is either a space or a tab.
// 2) A string surrounded by double quotation marks is interpreted as a single argument,
// regardless of white space contained within. A quoted string can be embedded in an argument.
// 3) A double quotation mark preceded by a backslash is interpreted as a literal double quotation mark.
// 4) Backslashes are interpreted literally, unless they immediately precede a double quotation mark.
// 5) If backslashes immediately precede a double quotation mark,
// every pair of backslashes is interpreted as a literal backslash.
// If the number of backslashes is odd, the last backslash escapes the next double quotation mark as described in rule 3.
func list2Cmdline(cmd string) []string {
var cmdParts []string
var inQuote rune

var b strings.Builder
for i, ch := range cmd {
if (ch == '"' || ch == '\'') && (i == 0 || cmd[i-1] != '\\') {
switch inQuote {
case rune(0):
inQuote = ch
case ch:
inQuote = rune(0)
default:
b.WriteRune(ch)
}
} else if (ch == ' ' || ch == '\t') && inQuote == 0 {
cmdParts = append(cmdParts, b.String())
b.Reset()
} else {
b.WriteRune(ch)
}
}
cmdParts = append(cmdParts, b.String())
return cmdParts
}

func getMeasurementMetrics(timing int64) (float64, string) {
for i, denominator := range denominators {
if timing/int64(denominator) > 0 {
return float64(denominator), units[i]
}
}
return 0, ""
}

func stdev(values []int64, mean int64) float64 {
var numerator int64
for _, value := range values {
delta := value - mean
numerator += delta * delta
}
return math.Sqrt(float64(numerator) / float64(len(values)-1))
}

func clearCurrentTerminalLine(w io.Writer) {
w.Write([]byte("\r\033[K"))
}

func printProgressLine(line string, progress float64, eta time.Duration) {
// Calculate progress bar
terminalWidth, _, _ := term.GetSize(int(os.Stdout.Fd()))
terminalWidth -= len(line) + 2 + 12
progressChunks := int(progress * float64(terminalWidth))
progressLine := strings.Repeat(progressDoneRune, progressChunks)
progressLine += strings.Repeat(progressPendingRune, terminalWidth-progressChunks)

fmt.Fprintf(color.Output, "%s %s ETA %02d:%02d:%02d", line, progressLine,
int64(eta.Hours()), int64(eta.Minutes()), int64(eta.Seconds()))
}

0 comments on commit 04babf7

Please sign in to comment.