f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
528 lines
16 KiB
Go
528 lines
16 KiB
Go
// Command benchgate compares two `go test -bench` outputs (baseline
|
|
// vs candidate) and exits non-zero when a benchmark regresses beyond
|
|
// configured thresholds.
|
|
//
|
|
// Parsing and statistics come from golang.org/x/perf — benchfmt for
|
|
// the benchmark format and benchmath (the engine behind benchstat)
|
|
// for summaries and significance tests. benchgate only adds the
|
|
// policy benchstat deliberately does not provide: thresholds, floors,
|
|
// and a failing exit code for CI.
|
|
//
|
|
// It is the comparison step of the bench-gate CI workflow: allocs/op
|
|
// and B/op are deterministic for the same code on the same machine,
|
|
// so they get tight ratio thresholds that catch O(archive)-instead-
|
|
// of-O(delta) work regressions regardless of sample count; time
|
|
// (sec/op) is noisy on shared runners, so it gets a loose threshold
|
|
// and additionally must be a statistically significant difference
|
|
// (Mann-Whitney U, as in benchstat) before it fails the gate.
|
|
// Baselines below a per-metric floor are skipped entirely, since a
|
|
// few extra allocations on a tiny benchmark is noise, not a
|
|
// regression.
|
|
//
|
|
// Multiple runs of the same benchmark (-count=N) are kept as a
|
|
// sample. The baseline is summarized by its median; the candidate is
|
|
// gated on its median for time but on its WORST run for allocs/op
|
|
// and B/op — those are deterministic, so a single outlier run there
|
|
// is a real intermittent allocation path, not noise, and must fail.
|
|
// Gating is per benchmark: any one benchmark over its threshold
|
|
// fails the gate; there is no cross-benchmark averaging. Benchmarks
|
|
// present on only one side are reported but never fail the gate, so
|
|
// adding or removing benchmarks in a PR does not wedge it. A gated
|
|
// unit missing from the baseline (a legitimately older or partial
|
|
// base run) is reported as not gated; a gated unit missing from the
|
|
// candidate (e.g. -benchmem dropped) is a configuration error, since
|
|
// it would otherwise silently disable that gate for good.
|
|
//
|
|
// Lines that look like benchmark results but fail to parse (for
|
|
// example test log output interleaved into a result line) are a
|
|
// corrupted capture. Candidate corruption exits 2, because it is
|
|
// under this workflow's control and would otherwise silently disable
|
|
// a gate. Baseline corruption is reported but treated as a partial
|
|
// baseline, because the merge base may legitimately predate fixes to
|
|
// the benchmark capture itself.
|
|
package main
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
|
|
"golang.org/x/perf/benchfmt"
|
|
"golang.org/x/perf/benchmath"
|
|
"golang.org/x/perf/benchunit"
|
|
)
|
|
|
|
// minTimeSamples is the per-side sample count the sec/op
|
|
// significance test needs before its verdict means anything.
|
|
const minTimeSamples = 5
|
|
|
|
// benchSamples collects every measured value per benchmark and unit:
|
|
// benchmark key -> tidied unit (sec/op, B/op, allocs/op, ...) ->
|
|
// samples across -count runs. The key includes the package path when
|
|
// the output carries one, so same-named benchmarks in different
|
|
// packages never merge.
|
|
type benchSamples map[string]map[string][]float64
|
|
|
|
// gate is one metric's regression rule: fail when the candidate
|
|
// exceeds the baseline median by more than maxRatio, unless the
|
|
// baseline is below floor (too small to compare meaningfully). With
|
|
// worstCase set, the candidate is judged by its worst (highest) run
|
|
// rather than its median — for deterministic metrics, where any
|
|
// outlier run is a real intermittent code path. With
|
|
// needSignificance set, the samples must also differ significantly
|
|
// under the benchmath comparison test — the benchstat noise guard,
|
|
// used for wall-clock time.
|
|
type gate struct {
|
|
unit string
|
|
maxRatio float64
|
|
floor float64
|
|
worstCase bool
|
|
needSignificance bool
|
|
}
|
|
|
|
// violation describes one gate failure.
|
|
type violation struct {
|
|
name string
|
|
unit string
|
|
old, new float64
|
|
ratio float64
|
|
maxRatio float64
|
|
}
|
|
|
|
// configIssue describes a capture that cannot support the gate it
|
|
// was given (e.g. too few candidate samples for significance
|
|
// testing) — a CI configuration error, not a regression.
|
|
type configIssue struct {
|
|
name string
|
|
msg string
|
|
}
|
|
|
|
func (v violation) String() string {
|
|
cls := benchunit.ClassOf(v.unit)
|
|
return fmt.Sprintf(
|
|
"%s: %s regressed %.2fx (%s -> %s, limit %.2fx)",
|
|
v.name, v.unit, v.ratio,
|
|
benchunit.Scale(v.old, cls), benchunit.Scale(v.new, cls),
|
|
v.maxRatio,
|
|
)
|
|
}
|
|
|
|
// parseBench extracts benchmark samples from `go test -bench` output
|
|
// using the official format parser. Values arrive tidied by
|
|
// benchfmt: ns/op becomes sec/op, MB/s becomes B/s. Lines that look
|
|
// like results but fail to parse are returned as syntax errors so a
|
|
// corrupted capture is loud instead of silently missing benchmarks.
|
|
func parseBench(
|
|
reader *benchfmt.Reader,
|
|
) (benchSamples, []string, error) {
|
|
out := make(benchSamples)
|
|
var syntaxErrs []string
|
|
for reader.Scan() {
|
|
res, ok := reader.Result().(*benchfmt.Result)
|
|
if !ok {
|
|
if serr, isSyntax := reader.Result().(*benchfmt.SyntaxError); isSyntax {
|
|
syntaxErrs = append(syntaxErrs, serr.Error())
|
|
}
|
|
continue
|
|
}
|
|
name := string(res.Name.Full())
|
|
if pkg := res.GetConfig("pkg"); pkg != "" {
|
|
name = pkg + "." + name
|
|
}
|
|
units := out[name]
|
|
if units == nil {
|
|
units = make(map[string][]float64)
|
|
out[name] = units
|
|
}
|
|
for _, v := range res.Values {
|
|
units[v.Unit] = append(units[v.Unit], v.Value)
|
|
}
|
|
}
|
|
if err := reader.Err(); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return out, syntaxErrs, nil
|
|
}
|
|
|
|
// evalGate applies one gate to one benchmark's samples and returns
|
|
// the report fragment plus an optional violation or config issue
|
|
// (their name fields are filled in by the caller).
|
|
func evalGate(
|
|
g gate, oldVals, newVals []float64,
|
|
) (string, *violation, *configIssue) {
|
|
thresholds := benchmath.DefaultThresholds
|
|
oldSample := benchmath.NewSample(oldVals, &thresholds)
|
|
newSample := benchmath.NewSample(newVals, &thresholds)
|
|
oldCenter := benchmath.AssumeNothing.
|
|
Summary(oldSample, 0.95).Center
|
|
var newCenter float64
|
|
if g.worstCase {
|
|
// Samples are sorted ascending; the worst candidate run
|
|
// is the last one.
|
|
newCenter = newSample.Values[len(newSample.Values)-1]
|
|
} else {
|
|
newCenter = benchmath.AssumeNothing.
|
|
Summary(newSample, 0.95).Center
|
|
}
|
|
cls := benchunit.ClassOf(g.unit)
|
|
span := fmt.Sprintf(
|
|
"%s %s -> %s", g.unit,
|
|
benchunit.Scale(oldCenter, cls),
|
|
benchunit.Scale(newCenter, cls),
|
|
)
|
|
|
|
if oldCenter <= 0 || oldCenter < g.floor {
|
|
return fmt.Sprintf(
|
|
"%s (below %s floor, not gated)",
|
|
span, benchunit.Scale(g.floor, cls),
|
|
), nil, nil
|
|
}
|
|
if g.needSignificance && len(newVals) < minTimeSamples {
|
|
issue := &configIssue{msg: fmt.Sprintf(
|
|
"%s needs at least %d candidate samples for significance gating, got %d",
|
|
g.unit, minTimeSamples, len(newVals),
|
|
)}
|
|
return span + " (too few candidate samples, not gated)",
|
|
nil, issue
|
|
}
|
|
if g.needSignificance && len(oldVals) < minTimeSamples {
|
|
// A short baseline is not a configuration error: the base
|
|
// run may legitimately be partial (e.g. it failed part-way
|
|
// and the workflow gates against what it produced).
|
|
return fmt.Sprintf(
|
|
"%s (baseline has only %d sample(s), significance needs %d, not gated)",
|
|
span, len(oldVals), minTimeSamples,
|
|
), nil, nil
|
|
}
|
|
|
|
ratio := newCenter / oldCenter
|
|
detail, significant := gateDetail(
|
|
g, oldSample, newSample, span, ratio,
|
|
)
|
|
var v *violation
|
|
if ratio > g.maxRatio && (!g.needSignificance || significant) {
|
|
v = &violation{
|
|
unit: g.unit,
|
|
old: oldCenter, new: newCenter,
|
|
ratio: ratio, maxRatio: g.maxRatio,
|
|
}
|
|
}
|
|
return detail, v, nil
|
|
}
|
|
|
|
// gateDetail renders the gated report fragment and, for
|
|
// significance-gated units, runs the benchmath comparison.
|
|
func gateDetail(
|
|
g gate, oldSample, newSample *benchmath.Sample,
|
|
span string, ratio float64,
|
|
) (string, bool) {
|
|
if g.worstCase {
|
|
// Also surface the baseline's worst run: the gate is
|
|
// deliberately candidate-worst vs baseline-median, so
|
|
// pre-existing baseline instability should at least be
|
|
// visible when reading a failure.
|
|
cls := benchunit.ClassOf(g.unit)
|
|
oldWorst := oldSample.Values[len(oldSample.Values)-1]
|
|
return fmt.Sprintf(
|
|
"%s (%.2fx, limit %.2fx, worst of %d run(s), baseline worst %s)",
|
|
span, ratio, g.maxRatio, len(newSample.Values),
|
|
benchunit.Scale(oldWorst, cls),
|
|
), false
|
|
}
|
|
cmp := benchmath.AssumeNothing.Compare(oldSample, newSample)
|
|
significant := cmp.P < cmp.Alpha
|
|
detail := fmt.Sprintf(
|
|
"%s (%.2fx, limit %.2fx, %s)", span, ratio, g.maxRatio, cmp,
|
|
)
|
|
if g.needSignificance && !significant {
|
|
detail += " [not significant, not gated]"
|
|
}
|
|
return detail, significant
|
|
}
|
|
|
|
// compare applies the gates to every benchmark present in both maps
|
|
// and returns a human-readable report plus the violations and
|
|
// config issues.
|
|
func compare(
|
|
oldRes, newRes benchSamples, gates []gate,
|
|
) (report []string, violations []violation, issues []configIssue) {
|
|
names := make([]string, 0, len(newRes))
|
|
for name := range newRes {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
|
|
for _, name := range names {
|
|
oldUnits, ok := oldRes[name]
|
|
if !ok {
|
|
report = append(report, fmt.Sprintf(
|
|
"%s: new benchmark, no baseline to compare", name,
|
|
))
|
|
continue
|
|
}
|
|
parts, vs, is := compareUnits(gates, oldUnits, newRes[name])
|
|
for i := range vs {
|
|
vs[i].name = name
|
|
}
|
|
for i := range is {
|
|
is[i].name = name
|
|
}
|
|
violations = append(violations, vs...)
|
|
issues = append(issues, is...)
|
|
report = append(report, fmt.Sprintf(
|
|
"%s: %s", name, strings.Join(parts, ", "),
|
|
))
|
|
}
|
|
|
|
var removed []string
|
|
for name := range oldRes {
|
|
if _, ok := newRes[name]; !ok {
|
|
removed = append(removed, name)
|
|
}
|
|
}
|
|
sort.Strings(removed)
|
|
for _, name := range removed {
|
|
report = append(report, fmt.Sprintf(
|
|
"%s: present in baseline but missing from candidate",
|
|
name,
|
|
))
|
|
}
|
|
return report, violations, issues
|
|
}
|
|
|
|
// compareUnits evaluates every gate for one benchmark, plus a note
|
|
// for candidate units no gate covers (custom b.ReportMetric units
|
|
// are collected but deliberately never gated).
|
|
func compareUnits(
|
|
gates []gate, oldUnits, newUnits map[string][]float64,
|
|
) (parts []string, vs []violation, is []configIssue) {
|
|
gated := make(map[string]bool, len(gates))
|
|
for _, g := range gates {
|
|
gated[g.unit] = true
|
|
oldVals, okOld := oldUnits[g.unit]
|
|
newVals, okNew := newUnits[g.unit]
|
|
switch {
|
|
case !okOld && !okNew:
|
|
// The benchmark doesn't emit this unit at all.
|
|
continue
|
|
case !okOld:
|
|
// A baseline may legitimately lack a unit (older or
|
|
// partial base run): report, don't gate.
|
|
parts = append(parts, fmt.Sprintf(
|
|
"%s missing from baseline, not gated", g.unit,
|
|
))
|
|
continue
|
|
case !okNew:
|
|
// The candidate capture is under the workflow's
|
|
// control; losing a gated unit the baseline has (e.g.
|
|
// -benchmem dropped) would silently disable this gate,
|
|
// so it is a configuration error.
|
|
parts = append(parts, fmt.Sprintf(
|
|
"%s missing from candidate", g.unit,
|
|
))
|
|
is = append(is, configIssue{msg: fmt.Sprintf(
|
|
"%s present in baseline but missing from candidate capture (was -benchmem dropped?)",
|
|
g.unit,
|
|
)})
|
|
continue
|
|
}
|
|
part, v, issue := evalGate(g, oldVals, newVals)
|
|
parts = append(parts, part)
|
|
if v != nil {
|
|
vs = append(vs, *v)
|
|
}
|
|
if issue != nil {
|
|
is = append(is, *issue)
|
|
}
|
|
}
|
|
var custom []string
|
|
for unit := range newUnits {
|
|
if !gated[unit] {
|
|
custom = append(custom, unit)
|
|
}
|
|
}
|
|
sort.Strings(custom)
|
|
for _, unit := range custom {
|
|
parts = append(parts, fmt.Sprintf(
|
|
"%s has no gate, not gated", unit,
|
|
))
|
|
}
|
|
return parts, vs, is
|
|
}
|
|
|
|
func parseFile(path string) (benchSamples, []string, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer f.Close()
|
|
return parseBench(benchfmt.NewReader(f, path))
|
|
}
|
|
|
|
// results bundles everything render needs to produce the final
|
|
// output and exit code.
|
|
type results struct {
|
|
report []string
|
|
violations []violation
|
|
issues []configIssue
|
|
newCount int
|
|
oldSyntax, newSyntax []string
|
|
}
|
|
|
|
// render formats the human-readable outcome and picks the exit code:
|
|
// 2 for unusable candidate input or configuration errors, 1 for
|
|
// regressions, 0 otherwise. Baseline syntax errors are reported as a
|
|
// partial baseline. Violations always print, even when a config issue
|
|
// or corrupted candidate capture also occurred, so a detected
|
|
// regression is never hidden behind an exit-2.
|
|
func render(r results) (string, int) {
|
|
var b strings.Builder
|
|
for _, line := range r.report {
|
|
fmt.Fprintln(&b, line)
|
|
}
|
|
if len(r.violations) > 0 {
|
|
fmt.Fprintf(&b, "\nbenchgate: %d regression(s):\n",
|
|
len(r.violations))
|
|
for _, v := range r.violations {
|
|
fmt.Fprintf(&b, " %s\n", v)
|
|
}
|
|
}
|
|
renderSyntax(&b, "baseline", r.oldSyntax)
|
|
renderSyntax(&b, "candidate", r.newSyntax)
|
|
if len(r.issues) > 0 {
|
|
fmt.Fprintln(&b, "\nbenchgate: invalid benchmark configuration:")
|
|
for _, issue := range r.issues {
|
|
fmt.Fprintf(&b, " %s: %s\n", issue.name, issue.msg)
|
|
}
|
|
}
|
|
switch {
|
|
case len(r.newSyntax) > 0 || len(r.issues) > 0:
|
|
return b.String(), 2
|
|
case r.newCount == 0:
|
|
fmt.Fprintln(&b, "benchgate: candidate output contains no benchmarks")
|
|
return b.String(), 2
|
|
case len(r.violations) > 0:
|
|
return b.String(), 1
|
|
}
|
|
fmt.Fprintln(&b, "benchgate: no regressions beyond thresholds")
|
|
return b.String(), 0
|
|
}
|
|
|
|
// renderSyntax reports unparseable result lines in one capture. A
|
|
// benchmark whose result line is corrupted (e.g. by interleaved log
|
|
// output) parses on neither side and would otherwise vanish from
|
|
// the gate without a trace.
|
|
func renderSyntax(b *strings.Builder, side string, errs []string) {
|
|
if len(errs) == 0 {
|
|
return
|
|
}
|
|
fmt.Fprintf(
|
|
b,
|
|
"\nbenchgate: %s capture is corrupted (%d unparseable result line(s); benchmarks on those lines are not gated):\n",
|
|
side, len(errs),
|
|
)
|
|
for _, e := range errs {
|
|
fmt.Fprintf(b, " %s\n", e)
|
|
}
|
|
}
|
|
|
|
// flags holds the parsed command line.
|
|
type flags struct {
|
|
oldPath, newPath string
|
|
gates []gate
|
|
}
|
|
|
|
func parseFlags() flags {
|
|
oldPath := flag.String(
|
|
"old", "", "baseline `go test -bench` output file",
|
|
)
|
|
newPath := flag.String(
|
|
"new", "", "candidate `go test -bench` output file",
|
|
)
|
|
maxTimeRatio := flag.Float64(
|
|
"max-time-ratio", 2.0,
|
|
"fail when candidate median sec/op exceeds baseline by this factor "+
|
|
"(only when the difference is statistically significant; needs at "+
|
|
"least 5 candidate samples)",
|
|
)
|
|
maxAllocRatio := flag.Float64(
|
|
"max-alloc-ratio", 1.25,
|
|
"fail when candidate worst-run allocs/op exceeds baseline median by this factor",
|
|
)
|
|
maxBytesRatio := flag.Float64(
|
|
"max-bytes-ratio", 1.35,
|
|
"fail when candidate worst-run B/op exceeds baseline median by this factor",
|
|
)
|
|
timeFloorNs := flag.Float64(
|
|
"time-floor-ns", 100_000,
|
|
"skip the time gate when the baseline is below this many ns",
|
|
)
|
|
allocFloor := flag.Float64(
|
|
"alloc-floor", 64,
|
|
"skip the allocs/op gate when the baseline is below this",
|
|
)
|
|
bytesFloor := flag.Float64(
|
|
"bytes-floor", 16_384,
|
|
"skip the B/op gate when the baseline is below this",
|
|
)
|
|
flag.Parse()
|
|
|
|
if *oldPath == "" || *newPath == "" {
|
|
fmt.Fprintln(os.Stderr, "benchgate: -old and -new are required")
|
|
flag.Usage()
|
|
os.Exit(2)
|
|
}
|
|
return flags{
|
|
oldPath: *oldPath,
|
|
newPath: *newPath,
|
|
gates: []gate{
|
|
{
|
|
unit: "allocs/op",
|
|
maxRatio: *maxAllocRatio,
|
|
floor: *allocFloor,
|
|
worstCase: true,
|
|
},
|
|
{
|
|
unit: "B/op",
|
|
maxRatio: *maxBytesRatio,
|
|
floor: *bytesFloor,
|
|
worstCase: true,
|
|
},
|
|
{
|
|
unit: "sec/op",
|
|
maxRatio: *maxTimeRatio,
|
|
floor: *timeFloorNs / 1e9,
|
|
needSignificance: true,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
cfg := parseFlags()
|
|
oldRes, oldSyntax, err := parseFile(cfg.oldPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "benchgate: reading baseline: %v\n", err)
|
|
os.Exit(2)
|
|
}
|
|
newRes, newSyntax, err := parseFile(cfg.newPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "benchgate: reading candidate: %v\n", err)
|
|
os.Exit(2)
|
|
}
|
|
|
|
report, violations, issues := compare(oldRes, newRes, cfg.gates)
|
|
out, code := render(results{
|
|
report: report,
|
|
violations: violations,
|
|
issues: issues,
|
|
newCount: len(newRes),
|
|
oldSyntax: oldSyntax,
|
|
newSyntax: newSyntax,
|
|
})
|
|
fmt.Print(out)
|
|
os.Exit(code)
|
|
}
|