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 —
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.Loopkeeps 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 ab.Nloop.allocs/opis a key,ns/opis not shows whatb.Loopfixed, and the Rust library'sblack_boxis 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-countis the measurement. The Rust library's Timing a block ↗ makes the case. - Comparing
ns/opacross machines, or across a Go upgrade without re-running the old side. Nothing checks it, and the number is meaningless —allocs/opis a key,ns/opis not. - Setup timed inside a
b.Nloop. The old loop shape ran the benchmark function several times and counted whatever came before the loop each time; theentered 2 time(s)andentered 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
pprofwill happily rank them — A CPU profile names the hot function. AllocsPerRunon 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 —AllocsPerRuncounts allocations.