Skip to content

Performance: what go vet and gofmt catch

Level: 301 · anyone who runs go vet and wonders what it would say about a benchmark

One line: The performance tools that report are the compiler's — -gcflags=-m for inlining, -d=ssa/check_bce for bounds checks, the lessons of this chapter — and go vet's part is four analyzers at the edges: tests for a benchmark whose name is malformed, unusedresult for a Builder.String() thrown away, printf for a b.Logf with the wrong verb, and copylocks, which is silent on a copied strings.Builder and leaves the catch to the Builder itself, at run time.

gofmt -s has nothing to say about any program on this page. Neither analyzer below needs a flag: go vet . runs them all, and go test runs a subset of them — tests and printf included — before every test build, as the transcripts show.

tests: a benchmark with a malformed name

Benchmarkjoin is not a benchmark, because the letter after Benchmark is lower case; go test -bench=. would skip it without a word.

Verified output of performance_vet_tests_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go mod init example
$ go vet .
# example
# [example]
./line_test.go:10:6: Benchmarkjoin has malformed name: first letter after 'Benchmark' must not be lowercase
exit status 1
$ go test -bench=. -run='^$'
# example
# [example]
./line_test.go:10:6: Benchmarkjoin has malformed name: first letter after 'Benchmark' must not be lowercase
FAIL    example [build failed]
exit status 1
$ sed -i.bak s/Benchmarkjoin/BenchmarkJoin/ line_test.go && go vet .
exit status 0

The warning means — The tests analyzer ↗ checks the names and signatures of Test, Benchmark, Fuzz and Example functions; a name that go test would not recognise as a benchmark is reported. Because go test runs this analyzer itself, the test build fails too — better than a silent skip. The wrong-signature case is the go command's own check, on the compiler errors page.

The good version — the third command: BenchmarkJoin, and vet exits 0.

When the tool is right, and when it is noise — Always right: there is no reason to want a function named Benchmarkjoin in a test file. A helper that a benchmark calls should not start with Benchmark at all.

unusedresult: a Builder.String() thrown away

A function built a label in a strings.Builder, called String() and dropped the result.

Verified output of performance_vet_unusedresult_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go mod init example
$ go vet .
# example
# [example]
./main.go:15:2: result of (*strings.Builder).String call not used
exit status 1
$ sed -i.bak "s/^\tb.String() .*/\tline = b.String()/" main.go && go vet . && go run .
"order:42"
exit status 0

The warning means — unusedresult ↗ knows a list of functions and methods whose result is the whole point — fmt.Sprintf, errors.New, strings.Builder.String and others — and reports a call whose result goes nowhere. String() does not flush, reset or print; the call did nothing.

The good version — the second command: line = b.String(), vet exits 0, and the program prints "order:42".

When the tool is right, and when it is noise — Right whenever it fires on String, Error or Sprintf; a call kept only for its side effect on one of those methods is a call to rewrite. It is silent on a method of your own type, unless you add it with -unusedresult.funcs.

copylocks: silent on a copied strings.Builder

A function took a strings.Builder by value, wrote to the copy, and returned its String().

Verified output of performance_vet_builder_copy_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go mod init example
$ go vet .
exit status 0
$ go build -o order . && ./order
panic: strings: illegal use of non-zero Builder copied by value
exit status 2

The warning means — There is none: copylocks ↗ looks for values containing a sync.Mutex or another Locker, and a Builder holds neither. The Builder guards itself instead: builder.go ↗ keeps a pointer to its own address, and the first write through a copy whose pointer does not match panics with strings: illegal use of non-zero Builder copied by value, exit status 2. The copy shares the bytes already written, and a write through it could clobber the original's — the panic is the cheaper outcome.

The good version — pass a pointer:

func appendReference(b *strings.Builder, reference string) string {
    b.WriteString(reference)
    return b.String()
}

When the tool is right, and when it is noise — The tool is neither: this is a case no analyzer in the standard distribution reports, and a test that exercises the copying path finds it in a second. staticcheck ↗ and golangci-lint ↗ are the tools that would flag it statically; neither is run in this library.

printf: b.Logf with the wrong verb

A benchmark logged its field count with %d and passed a string.

Verified output of performance_vet_printf_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go mod init example
$ go vet .
# example
# [example]
./line_test.go:14:17: (*testing.common).Logf format %d has arg "two" of wrong type string
exit status 1
$ go test -bench=. -run='^$'
# example
# [example]
./line_test.go:14:17: (*testing.common).Logf format %d has arg "two" of wrong type string
FAIL    example [build failed]
exit status 1

The warning means — The printf analyzer ↗ knows that testing.common.Logf — the Logf of both *testing.T and *testing.B — is a printf-style function, and checks each verb against its argument. go test runs the same analyzer before building the test binary, so the benchmark never runs.

The good version —

b.Logf("joined %d fields", len(fields))

When the tool is right, and when it is noise — Right every time it fires on a standard-library function; the only noise is a wrapper of your own that the analyzer has not been told about, and it learns those by inference when the wrapper is in the same package.

What no tool catches

  • A benchmark whose loop body the compiler folds. b.Loop keeps the arguments and results of calls inside the loop alive by not inlining them; an expression written inline on values that never change gets no such protection, and neither does anything in a b.N loop. allocs/op is a key, ns/op is not shows what b.Loop fixed, and the Rust library's black_box is a hint ↗ shows the residue that no such hint can fix.
  • One time.Now() around one call. It compiles, vets clean and reports a single sample of a noisy process; a benchmark with -count is the measurement. The Rust library's Timing a block ↗ makes the case.
  • Comparing ns/op across machines, or across a Go upgrade without re-running the old side. Nothing checks it, and the number is meaningless — allocs/op is a key, ns/op is not.
  • Setup timed inside a b.N loop. The old loop shape ran the benchmark function several times and counted whatever came before the loop each time; the entered 2 time(s) and entered 26 time(s) lines on the same page are that cost made visible.
  • A CPU profile of a run too short to sample. Twenty milliseconds is two samples, and pprof will happily rank them — A CPU profile names the hot function.
  • AllocsPerRun on a constant. Boxing a literal costs nothing and boxing the same value from a variable costs an allocation; the count is only as good as the inputs — AllocsPerRun counts allocations.