Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -142,24 +142,39 @@ jobs:
shell: bash
working-directory: base
run: |
go test -run='^$' -bench=. -count=10 -benchmem -timeout 60m ./... 2>&1 | tee ../base.txt || true
go test -run='^$' -bench=. -count=10 -benchmem -timeout 60m -json ./... > ../base.json 2>&1 || true

- name: Run benchmarks (head)
shell: bash
working-directory: head
run: |
go test -run='^$' -bench=. -count=10 -benchmem -timeout 60m ./... 2>&1 | tee ../head.txt || true
go test -run='^$' -bench=. -count=10 -benchmem -timeout 60m -json ./... > ../head.json 2>&1 || true

- name: "📊 Compare and check"
shell: bash
run: |
go -C _go-infra tool benchstat "$GITHUB_WORKSPACE/base.txt" "$GITHUB_WORKSPACE/head.txt" | tee benchstat.txt || true
go build -C _go-infra/cmd/benchcheck -o "$PWD/benchcheck" .
# benchcheck reads the test2json output for structured failure
# detection and reconstructs the plain benchmark text (base.txt and
# head.txt) that the benchstat step below consumes. It runs last in
# this step, so its exit code marks the job failed on a regression or
# failure; the benchstat step still runs via if: always().
./benchcheck check \
-o-base-text=base.txt \
-o-head-text=head.txt \
-o-regressions=regressions.txt \
-o-failures=failures.txt \
-o-status=status.json \
base.txt head.txt
base.json head.json

- name: "📈 Summarize with benchstat"
# Run even when the check step marked the job failed, so the human-
# readable diff is always produced. benchcheck writes base.txt/head.txt
# before exiting, so they are available here regardless.
if: always()
shell: bash
run: |
go -C _go-infra tool benchstat "$GITHUB_WORKSPACE/base.txt" "$GITHUB_WORKSPACE/head.txt" | tee benchstat.txt || true

@dagood dagood Jul 7, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make more sense to run benchstat as part of benchcheck, avoiding some shuffling of data through bash (and through files)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took another look into this and the pinned x/perf only exposes the deprecated benchstat library whose output differs from the cmd/benchstat CLI we use. Reproducing the current format in-process would mean coupling to benchstat's internals and I feel like that would be a lot of work for a smaller result so maybe I would lean towards keeping it as a CLI call

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not talking about it being in-process necessarily--the benchcheck tool could use os/exec. Maybe it's a wash, with the changes needed to do that. 🤷

(If nobody will be attempting this sequence of commands locally, we also don't have that reason to try to make benchcheck handle more for them.)


- name: Upload results
if: always()
Expand Down
110 changes: 27 additions & 83 deletions cmd/benchcheck/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
package main

import (
"bufio"
"encoding/json"
"errors"
"flag"
Expand Down Expand Up @@ -47,15 +46,20 @@ func cmdCheck(args []string) {
regressionsOut := fs.String("o-regressions", "", "output file for regression details (skipped if empty)")
failuresOut := fs.String("o-failures", "", "output file for test failure details (skipped if empty)")
statusOut := fs.String("o-status", "", "output file for machine-readable status JSON (skipped if empty)")
baseTextOut := fs.String("o-base-text", "", "output file for reconstructed base benchmark text, for benchstat (skipped if empty)")
headTextOut := fs.String("o-head-text", "", "output file for reconstructed head benchmark text, for benchstat (skipped if empty)")
fs.Usage = func() {
fmt.Fprintf(os.Stderr, `usage: benchcheck check [flags] base.txt head.txt
fmt.Fprintf(os.Stderr, `usage: benchcheck check [flags] base.json head.json

Compare base and head benchmark results, detect regressions and test failures.
The inputs are `+"`go test -json`"+` (test2json) output files.

Writes the following output files (only if the corresponding flag is set):
-o-regressions One-line summary per detected regression.
-o-failures Extracted build errors, test failures, and crash traces.
-o-status Machine-readable JSON status for the report subcommand.
-o-base-text Reconstructed plain-text base output, for benchstat.
-o-head-text Reconstructed plain-text head output, for benchstat.

Exits 0 if no issues are found, 1 if regressions, failures, or write errors occur.

Expand All @@ -80,29 +84,26 @@ Flags:

basePath, headPath := fs.Arg(0), fs.Arg(1)

// Extract test failures from both files.
var failureLines []string
var err error
if failureLines, err = appendFailuresFromFile(failureLines, basePath, "base: "); err != nil {
fmt.Fprintf(os.Stderr, "reading %s: %v\n", basePath, err)
}
if failureLines, err = appendFailuresFromFile(failureLines, headPath, "head: "); err != nil {
fmt.Fprintf(os.Stderr, "reading %s: %v\n", headPath, err)
}
hasFailures := len(failureLines) > 0

// Parse benchmarks and check regressions.
// Parse the test2json inputs once each: this yields both the extracted
// failures and the reconstructed plain-text benchmark output.
var benchError bool
baseValues, err := parseBenchmarksFromFile(basePath)
base, err := parseTestJSONFile(basePath, "base: ")
if err != nil {
fmt.Fprintf(os.Stderr, "reading %s: %v\n", basePath, err)
benchError = true
}
headValues, err := parseBenchmarksFromFile(headPath)
head, err := parseTestJSONFile(headPath, "head: ")
if err != nil {
fmt.Fprintf(os.Stderr, "reading %s: %v\n", headPath, err)
benchError = true
}

failureLines := append(append([]string{}, base.Failures...), head.Failures...)
hasFailures := len(failureLines) > 0

// Parse benchmarks from the reconstructed text and check regressions.
baseValues := parseBenchmarks(strings.NewReader(base.BenchText), basePath)
headValues := parseBenchmarks(strings.NewReader(head.BenchText), headPath)
regressions := checkRegressions(baseValues, headValues, cfg)
hasRegressions := len(regressions) > 0

Expand All @@ -121,6 +122,8 @@ Flags:
}
}
writeErr := errors.Join(
writeTextFile(*baseTextOut, base.BenchText),
writeTextFile(*headTextOut, head.BenchText),
writeLines(*regressionsOut, regressionLines),
writeLines(*failuresOut, failureLines),
writeStatus(*statusOut, Status{
Expand Down Expand Up @@ -161,73 +164,6 @@ Flags:
}
}

func appendFailuresFromFile(lines []string, path, prefix string) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return lines, err
}
defer f.Close()
result, err := extractFailures(f, prefix)
return append(lines, result...), err
}

// extractFailures parses go test output and returns lines related to
// build errors, test failures, and crash traces.
func extractFailures(r io.Reader, prefix string) ([]string, error) {
var lines []string
scanner := bufio.NewScanner(r)
// go test output can contain very long lines (long panic frames, generated
// file paths, JSON-formatted log lines). Bump the limit well above the
// 64KiB default so Scan does not bail out partway through with ErrTooLong.
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
inCrash := false
for scanner.Scan() {
line := scanner.Text()
switch {
case strings.HasPrefix(line, "# "):
lines = append(lines, prefix+line)
case strings.HasPrefix(line, "--- FAIL"):
lines = append(lines, prefix+line)
case strings.HasPrefix(line, "FAIL\t"):
lines = append(lines, prefix+line)
case !inCrash && isCrashLine(line):
inCrash = true
lines = append(lines, prefix+line)
case inCrash && line == "":
inCrash = false
lines = append(lines, "")
case inCrash:
lines = append(lines, prefix+line)
}
}
return lines, scanner.Err()
}

// isCrashLine returns true for signal or panic lines (e.g. "SIGSEGV:", "panic:").
func isCrashLine(line string) bool {
if strings.HasPrefix(line, "panic:") {
return true
}
if !strings.HasPrefix(line, "SIG") {
return false
}
// Match SIG followed by uppercase letters then ':'.
i := 3
for i < len(line) && line[i] >= 'A' && line[i] <= 'Z' {
i++
}
return i > 3 && i < len(line) && line[i] == ':'
}

func parseBenchmarksFromFile(path string) (map[benchKey][]float64, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return parseBenchmarks(f, path), nil
}

func parseBenchmarks(r io.Reader, name string) map[benchKey][]float64 {
result := make(map[benchKey][]float64)
reader := benchfmt.NewReader(r, name)
Expand Down Expand Up @@ -325,6 +261,14 @@ func writeLines(path string, lines []string) error {
return writeFile(path, data)
}

// writeTextFile writes content verbatim to path, skipping if path is empty.
func writeTextFile(path, content string) error {
if path == "" {
return nil
}
return writeFile(path, []byte(content))
}

// Status is the machine-readable status written by check and read by report.
type Status struct {
Regression bool `json:"regression"`
Expand Down
2 changes: 1 addition & 1 deletion cmd/benchcheck/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//
// Commands:
//
// benchcheck check [flags] base.txt head.txt
// benchcheck check [flags] base.json head.json
// benchcheck report [flags] results-dir
package main

Expand Down
Loading
Loading