|
@@ -5,7 +5,10 @@ package eval
|
|
|
import (
|
|
|
"errors"
|
|
|
"fmt"
|
|
|
+ "math"
|
|
|
+ "math/big"
|
|
|
"net"
|
|
|
+ "strconv"
|
|
|
"sync"
|
|
|
"time"
|
|
|
|
|
@@ -16,10 +19,8 @@ import (
|
|
|
)
|
|
|
|
|
|
var (
|
|
|
- ErrNegativeSleepDuration = errors.New("sleep duration must be >= zero")
|
|
|
- ErrInvalidSleepDuration = errors.New("invalid sleep duration")
|
|
|
- ErrInvalidBenchmarkDuration = errors.New("benchmark duration must be > zero")
|
|
|
- ErrInvalidBenchmarkIter = errors.New("benchmark iterations must be > zero")
|
|
|
+ ErrNegativeSleepDuration = errors.New("sleep duration must be >= zero")
|
|
|
+ ErrInvalidSleepDuration = errors.New("invalid sleep duration")
|
|
|
)
|
|
|
|
|
|
// Builtins that have not been put into their own groups go here.
|
|
@@ -38,9 +39,9 @@ func init() {
|
|
|
"deprecate": deprecate,
|
|
|
|
|
|
// Time
|
|
|
- "benchmark": benchmark,
|
|
|
"sleep": sleep,
|
|
|
"time": timeCmd,
|
|
|
+ "benchmark": benchmark,
|
|
|
|
|
|
"-ifaddrs": _ifaddrs,
|
|
|
})
|
|
@@ -535,118 +536,95 @@ func timeCmd(fm *Frame, opts timeOpt, f Callable) error {
|
|
|
//elvdoc:fn benchmark
|
|
|
//
|
|
|
// ```elvish
|
|
|
-// benchmark &min-iters=5 &min-time=10s &on-run=$nil &on-end=$nil $callable
|
|
|
+// benchmark &min-runs=5 &min-time=1s &on-end=$nil &on-run-end=$nil $callable
|
|
|
// ```
|
|
|
//
|
|
|
-// Runs `$callable` one or more times and call `$on-end` with the shortest
|
|
|
-// duration as a number in seconds. If `&on-end` is `$nil` (the default) print
|
|
|
-// the duration in human-readable form; e.g., 1.000564042s.
|
|
|
+// Runs `$callable` repeatedly, and reports statistics about how long each run
|
|
|
+// takes.
|
|
|
//
|
|
|
-// If `$callable` throws an exception, the exception is propagated after the
|
|
|
-// `&on-end` (or default) printing is done.
|
|
|
+// If the `&on-end` callback is not given, `benchmark` prints the average,
|
|
|
+// standard deviation, minimum and maximum of the time it took to run
|
|
|
+// `$callback`, and the number of runs. If the `&on-end` callback is given,
|
|
|
+// `benchmark` instead calls it with a map containing these metrics, keyed by
|
|
|
+// `avg`, `stddev`, `min`, `max` and `runs`. Each duration value (i.e. all
|
|
|
+// except `runs`) is given as the number of seconds.
|
|
|
//
|
|
|
-// If `$on-end` throws an exception, it is propagated, unless `$callable` has
|
|
|
-// already thrown an exception.
|
|
|
+// The number of runs is controlled by `&min-runs` and `&min-time`. The
|
|
|
+// `$callable` is run at least `&min-runs` times, **and** when the total
|
|
|
+// duration is at least `&min-time`.
|
|
|
+//
|
|
|
+// The `&min-runs` option must be a non-negative integer within the range of the
|
|
|
+// machine word.
|
|
|
+//
|
|
|
+// The `&min-time` option must be a string representing a non-negative duration,
|
|
|
+// specified as a sequence of decimal numbers with a unit suffix (the numbers
|
|
|
+// may have fractional parts), such as "300ms", "1.5h" and "1h45m7s". Valid time
|
|
|
+// units are "ns", "us" (or "µs"), "ms", "s", "m", "h".
|
|
|
+//
|
|
|
+// If `&on-run-end` is given, it is called after each call to `$callable`, with
|
|
|
+// the time that call took, given as the number of seconds.
|
|
|
//
|
|
|
-// The `&min-iters` option defines the minimum number of iterations to execute
|
|
|
-// regardless of the total run time. The default is five. The number of
|
|
|
-// iterations must be greater than zero.
|
|
|
+// If `$callable` throws an exception, `benchmark` terminates and propagates the
|
|
|
+// exception after the `&on-end` callback (or the default printing behavior)
|
|
|
+// finishes. The duration of the call that throws an exception is not passed to
|
|
|
+// `&on-run-end`, nor is it included when calculating the statistics for
|
|
|
+// `&on-end`. If the first call to `$callable` throws an exception and `&on-end`
|
|
|
+// is `$nil`, nothing is printed and any `&on-end` callback is not called.
|
|
|
//
|
|
|
-// The `&min-time` option defines the minimum duration to run the benchmark. The
|
|
|
-// default is ten seconds. The duration can be a simple
|
|
|
-// [number](language.html#number) (with optional fractional part) without an
|
|
|
-// explicit unit suffix -- in which case the unit is seconds. The duration can
|
|
|
-// also be a string written as a sequence of decimal numbers, each with optional
|
|
|
-// fraction, plus a unit suffix. For example, "300ms", "1.5h" or "1h45m7s".
|
|
|
-// Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". The duration
|
|
|
-// must be greater than or equal to zero.
|
|
|
+// If `&on-run-end` is given and throws an exception, `benchmark` terminates and
|
|
|
+// propagates the exception after the `&on-end` callback (or the default
|
|
|
+// printing behavior) finishes, unless `$callable` has already thrown an
|
|
|
+// exception
|
|
|
//
|
|
|
-// Note: A given execution must satisfy both the `&min-iters` and `&min-time`
|
|
|
-// constraints.
|
|
|
+// If `&on-end` throws an exception, the exception is propagated, unless
|
|
|
+// `$callable` or `&on-run-end` has already thrown an exception.
|
|
|
//
|
|
|
// Example:
|
|
|
//
|
|
|
// ```elvish-transcript
|
|
|
-// ~> var run-times = []
|
|
|
-// ~> benchmark &min-time=40s &min-iters=3 ^
|
|
|
-// &on-run={|x| set run-times = [$@run-times $x] } ^
|
|
|
-// &on-end={|x| echo "fastest "$x } { sleep 10s }
|
|
|
-// fastest 10.000564042
|
|
|
-// ~> pprint $run-times
|
|
|
-// [
|
|
|
-// (num 10.003042292)
|
|
|
-// (num 10.000564042)
|
|
|
-// (num 10.001751125)
|
|
|
-// (num 10.001130667)
|
|
|
-// ]
|
|
|
+// ~> benchmark { }
|
|
|
+// 98ns ± 382ns (min 0s, max 210.417µs, 10119226 runs)
|
|
|
+// ~> benchmark &on-end={|m| put $m[avg]} { }
|
|
|
+// ▶ (num 9.8e-08)
|
|
|
+// ~> benchmark &on-run-end={|d| echo $d} { sleep 0.3 }
|
|
|
+// 0.301123625
|
|
|
+// 0.30123775
|
|
|
+// 0.30119075
|
|
|
+// 0.300629166
|
|
|
+// 0.301260333
|
|
|
+// 301.088324ms ± 234.298µs (min 300.629166ms, max 301.260333ms, 5 runs)
|
|
|
// ```
|
|
|
//
|
|
|
// @cf time
|
|
|
|
|
|
-type benchmarkOpt struct {
|
|
|
+type benchmarkOpts struct {
|
|
|
OnEnd Callable
|
|
|
- OnRun Callable
|
|
|
- MinIters string
|
|
|
+ OnRunEnd Callable
|
|
|
+ MinRuns int
|
|
|
MinTime string
|
|
|
- minIters int
|
|
|
minTime time.Duration
|
|
|
}
|
|
|
|
|
|
-const (
|
|
|
- defaultMinIters = 5
|
|
|
- defaultMinTime = time.Duration(10 * time.Second)
|
|
|
-)
|
|
|
-
|
|
|
-func (o *benchmarkOpt) SetDefaultOptions() {
|
|
|
- o.minIters = defaultMinIters
|
|
|
- o.minTime = defaultMinTime
|
|
|
-}
|
|
|
-
|
|
|
-func benchmark(fm *Frame, opts benchmarkOpt, f Callable) error {
|
|
|
- if err := parseBenchmarkOpts(&opts); err != nil {
|
|
|
- return err
|
|
|
- }
|
|
|
- shortestDuration, err := runBenchmark(fm, opts, f)
|
|
|
- if opts.OnEnd == nil {
|
|
|
- _, errOut := fmt.Fprintln(fm.ByteOutput(), shortestDuration)
|
|
|
- if err == nil {
|
|
|
- err = errOut
|
|
|
- }
|
|
|
- } else {
|
|
|
- newFm := fm.Fork("on-end callback of benchmark")
|
|
|
- errOut := opts.OnEnd.Call(newFm, []any{shortestDuration.Seconds()}, NoOpts)
|
|
|
- if err == nil {
|
|
|
- err = errOut
|
|
|
- }
|
|
|
- }
|
|
|
- return err
|
|
|
+func (o *benchmarkOpts) SetDefaultOptions() {
|
|
|
+ o.MinRuns = 5
|
|
|
+ o.minTime = time.Second
|
|
|
}
|
|
|
|
|
|
-func parseBenchmarkOpts(opts *benchmarkOpt) error {
|
|
|
- if opts.MinIters != "" {
|
|
|
- if err := vals.ScanToGo(opts.MinIters, &opts.minIters); err != nil {
|
|
|
- return ErrInvalidBenchmarkIter
|
|
|
- }
|
|
|
- if opts.minIters <= 0 {
|
|
|
- return ErrInvalidBenchmarkIter
|
|
|
- }
|
|
|
+func (opts *benchmarkOpts) parse() error {
|
|
|
+ if opts.MinRuns < 0 {
|
|
|
+ return errs.BadValue{What: "min-runs option",
|
|
|
+ Valid: "non-negative integer", Actual: strconv.Itoa(opts.MinRuns)}
|
|
|
}
|
|
|
|
|
|
if opts.MinTime != "" {
|
|
|
- var d time.Duration
|
|
|
- var f float64
|
|
|
- if err := vals.ScanToGo(opts.MinTime, &f); err == nil {
|
|
|
- // It is a simple number we treat as a (fractional) number of seconds.
|
|
|
- d = time.Duration(f * float64(time.Second))
|
|
|
- } else {
|
|
|
- // See if it is a duration string rather than a simple number.
|
|
|
- d, err = time.ParseDuration(opts.MinTime)
|
|
|
- if err != nil {
|
|
|
- return ErrInvalidBenchmarkDuration
|
|
|
- }
|
|
|
+ d, err := time.ParseDuration(opts.MinTime)
|
|
|
+ if err != nil {
|
|
|
+ return errs.BadValue{What: "min-time option",
|
|
|
+ Valid: "duration string", Actual: parse.Quote(opts.MinTime)}
|
|
|
}
|
|
|
if d < 0 {
|
|
|
- return ErrInvalidBenchmarkDuration
|
|
|
+ return errs.BadValue{What: "min-time option",
|
|
|
+ Valid: "non-negative duration", Actual: parse.Quote(opts.MinTime)}
|
|
|
}
|
|
|
opts.minTime = d
|
|
|
}
|
|
@@ -654,35 +632,92 @@ func parseBenchmarkOpts(opts *benchmarkOpt) error {
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
-var TimeNow = time.Now // so unit testing can stub it
|
|
|
+// TimeNow is a reference to [time.Now] that can be overridden in tests.
|
|
|
+var TimeNow = time.Now
|
|
|
|
|
|
-func runBenchmark(fm *Frame, opts benchmarkOpt, f Callable) (time.Duration, error) {
|
|
|
- var totalTime time.Duration
|
|
|
- var shortestDuration time.Duration = 1<<63 - 1
|
|
|
- for iter := 1; ; iter++ {
|
|
|
+func benchmark(fm *Frame, opts benchmarkOpts, f Callable) error {
|
|
|
+ if err := opts.parse(); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+
|
|
|
+ // Standard deviation is calculated using https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford's_online_algorithm
|
|
|
+ var (
|
|
|
+ min = time.Duration(math.MaxInt64)
|
|
|
+ max = time.Duration(math.MinInt64)
|
|
|
+ runs int64
|
|
|
+ total time.Duration
|
|
|
+ m2 float64
|
|
|
+ err error
|
|
|
+ )
|
|
|
+ for {
|
|
|
t0 := TimeNow()
|
|
|
- err := f.Call(fm, NoArgs, NoOpts)
|
|
|
- t1 := TimeNow()
|
|
|
+ err = f.Call(fm, NoArgs, NoOpts)
|
|
|
if err != nil {
|
|
|
- return 0, err
|
|
|
+ break
|
|
|
+ }
|
|
|
+ dt := TimeNow().Sub(t0)
|
|
|
+
|
|
|
+ if min > dt {
|
|
|
+ min = dt
|
|
|
+ }
|
|
|
+ if max < dt {
|
|
|
+ max = dt
|
|
|
}
|
|
|
- dt := t1.Sub(t0)
|
|
|
- if dt < shortestDuration {
|
|
|
- shortestDuration = dt
|
|
|
+ var oldDelta float64
|
|
|
+ if runs > 0 {
|
|
|
+ oldDelta = float64(dt) - float64(total)/float64(runs)
|
|
|
}
|
|
|
- if opts.OnRun != nil {
|
|
|
- newFm := fm.Fork("on-run callback of benchmark")
|
|
|
- err := opts.OnRun.Call(newFm, []any{dt.Seconds()}, NoOpts)
|
|
|
+ runs++
|
|
|
+ total += dt
|
|
|
+ if runs > 0 {
|
|
|
+ newDelta := float64(dt) - float64(total)/float64(runs)
|
|
|
+ m2 += oldDelta * newDelta
|
|
|
+ }
|
|
|
+
|
|
|
+ if opts.OnRunEnd != nil {
|
|
|
+ newFm := fm.Fork("on-run-end callback of benchmark")
|
|
|
+ err = opts.OnRunEnd.Call(newFm, []any{dt.Seconds()}, NoOpts)
|
|
|
if err != nil {
|
|
|
- return 0, err
|
|
|
+ break
|
|
|
}
|
|
|
}
|
|
|
- totalTime += dt
|
|
|
- if iter >= opts.minIters && totalTime >= opts.minTime {
|
|
|
+
|
|
|
+ if runs >= int64(opts.MinRuns) && total >= opts.minTime {
|
|
|
break
|
|
|
}
|
|
|
}
|
|
|
- return shortestDuration, nil
|
|
|
+
|
|
|
+ if runs == 0 {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+
|
|
|
+ avg := total / time.Duration(runs)
|
|
|
+ stddev := time.Duration(math.Sqrt(m2 / float64(runs)))
|
|
|
+ if opts.OnEnd == nil {
|
|
|
+ _, errOut := fmt.Fprintf(fm.ByteOutput(),
|
|
|
+ "%v ± %v (min %v, max %v, %d runs)\n", avg, stddev, min, max, runs)
|
|
|
+ if err == nil {
|
|
|
+ err = errOut
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ stats := vals.MakeMap(
|
|
|
+ "avg", avg.Seconds(), "stddev", stddev.Seconds(),
|
|
|
+ "min", min.Seconds(), "max", max.Seconds(), "runs", int64ToElv(runs))
|
|
|
+ newFm := fm.Fork("on-end callback of benchmark")
|
|
|
+ errOnEnd := opts.OnEnd.Call(newFm, []any{stats}, NoOpts)
|
|
|
+ if err == nil {
|
|
|
+ err = errOnEnd
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return err
|
|
|
+}
|
|
|
+
|
|
|
+func int64ToElv(i int64) any {
|
|
|
+ if i <= int64(math.MaxInt) {
|
|
|
+ return int(i)
|
|
|
+ } else {
|
|
|
+ return big.NewInt(i)
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
//elvdoc:fn -ifaddrs
|