go / benchmarks
Each of my Go libraries and commands keeps benchmarks beside its tests.
A benchmark runs a hot path on input of a real size. It reports the
time, the bytes, and the allocations of one call. Before I merge a
change to a hot path, I compare it against main with benchstat.
A benchmark
My SQL formatter, pgfmt, has this benchmark:
func BenchmarkFormat(b *testing.B) {
cases := []struct{ name, in string }{
{"query", "select id, name from t where x = 1 order by id desc;"},
{"migration", benchFile},
{"large", strings.Repeat(benchFile+"\n", 100)},
{"depth", depth},
}
for _, c := range cases {
if _, err := Format(c.in); err != nil {
b.Fatalf("%s: %v", c.name, err)
}
b.Run(c.name, func(b *testing.B) {
b.ReportAllocs()
b.SetBytes(int64(len(c.in)))
for b.Loop() {
_, _ = Format(c.in)
}
})
}
}
b.Loop runs the body until the timer has enough samples. It stops the
compiler from removing a call whose result the loop discards. The timer
starts at the first call to b.Loop, so setup above the loop costs
nothing.
b.ReportAllocs adds the B/op and allocs/op columns. b.SetBytes
adds a MB/s column, so inputs of different sizes compare.
The benchmark calls Format one time before the loop and fails on an
error. A function that returns an error at once is fast, and a
benchmark of it measures only the error path.
Each case comes from real use: a short query, a typical migration, a large file, and the deepest nesting that the formatter accepts. A benchmark on a toy input finds the costs of a toy input.
I prepare the input before the loop. A string(src) conversion inside
the loop adds an allocation to each call, and the function under test
does not make it.
go test without -bench compiles each benchmark and does not run it.
So a benchmark that no longer builds fails the push, and a benchmark
adds no time to CI.
Benchmark the limits
My libraries take input from callers they do not trust. Each one has limits: a nesting depth, a body size, a tag length, an escape. I benchmark the input that a limit refuses as well as the input that it accepts:
// BenchmarkFormatError runs Format on input that checkDepth refuses. A
// caller with untrusted SQL pays this cost for each bad file.
func BenchmarkFormatError(b *testing.B) {
src := "select " + strings.Repeat("(", 100000) + "1;"
b.ReportAllocs()
b.SetBytes(int64(len(src)))
for b.Loop() {
_, _ = Format(src)
}
}
A refusal that costs as much as a success gives an attacker the same
work for free. My tunnel server, tund, compares Content-Length with
its limit before it reads the body. So a refused 1 MiB request costs
the server a few KiB.
A benchmark of a limit also shows its cost on good input. When the number is small, I keep the limit and stop there.
Compare with benchstat
benchstat reads the output of many runs. It reports the change of each benchmark and whether that change is significant.
I build one test binary from main and one from the change. Then I run
them in turns:
git worktree add /tmp/base main
(cd /tmp/base && go test -c -o /tmp/base.test)
go test -c -o /tmp/new.test
for i in $(seq 10); do
/tmp/base.test -test.run '^$' -test.bench . >> /tmp/base.txt
/tmp/new.test -test.run '^$' -test.bench . >> /tmp/new.txt
done
benchstat /tmp/base.txt /tmp/new.txt
-test.run '^$' skips the tests. The turns spread a burst of load on
the laptop over both binaries. Ten runs back to back would put the
burst on one side.
benchstat prints a ~ where the change is not significant. I claim
only the rows with a low p-value.
On a busy machine, time moves by tens of percent between runs of the
same binary. An allocation count does not move. When the time is too
noisy to read, the allocs/op column still shows whether a change did
what I wanted.
To find where the time goes, I profile one benchmark:
go test -run '^$' -bench 'Format/large' -cpuprofile /tmp/cpu.out
go tool pprof -top /tmp/cpu.out
-cpuprofile leaves the test binary, such as pgfmt.test, in the
package directory. I write the profile to /tmp and delete the binary,
so that neither one goes into a commit.
What the benchmarks find
The same causes come up in each repo.
fmt in a loop
fmt.Sprintf and fmt.Fprintf put each argument in an interface, and
each call can allocate. In a loop over points or lines, I append to one
buffer with strconv:
buf = strconv.AppendFloat(buf, x, 'f', 2, 64)
buf = append(buf, ',')
buf = strconv.AppendFloat(buf, y, 'f', 2, 64)
buf = append(buf, ' ')
My SVG chart package, chart, writes each polyline this way. My process manager, procman, writes each line of output into one buffer that it keeps from line to line. The mutex that orders the output also guards the buffer.
Size the buffer
A slice or a buffer that grows from zero copies itself each time it fills. When I know the size, or can estimate it, I allocate one time:
toks := make([]token, 0, len(src)/4+1)
pgfmt sizes its token slice from the input. My syntax highlighter,
highlight, starts its output at
three times the input, because HTML spans make the output that much
larger. A strings.Builder returns its bytes as a string with no copy.
A size from a caller is a size the caller chose. tund trusts
Content-Length only up to 64 KiB. Above that, it reads with
io.ReadAll, so a caller cannot make it allocate memory for bytes the
caller does not send.
Slice the input
A token that holds string(ch), or a copy of its bytes, allocates
once for each token. A token can hold src[i:j]. That substring
shares the memory of the source and does not allocate. pgfmt and
highlight both make tokens this way.
pgfmt also looks up each keyword in a map that holds both the
lowercase and the uppercase form. The lookup returns the uppercase
string, so no token calls strings.ToUpper.
An interface call for each element
image.Image.At returns a color.Color interface. For an
*image.RGBA, each call allocates. My headless browser command,
browse, records a page as a GIF,
and its palette step reads each pixel. image/png decodes Chrome's
frames to *image.RGBA or *image.NRGBA. For those two types, browse
reads Pix directly:
switch m := src.(type) {
case *image.RGBA:
return func(x, y int) color.RGBA {
i := m.PixOffset(x, y)
s := m.Pix[i : i+3 : i+3]
return color.RGBA{s[0], s[1], s[2], 0xff}
}
// ...
}
Any other image type still goes through At. A test compares the
color from Pix with the color from At.
Decode one time
browse reads Chrome DevTools Protocol messages. Each message has a
method name and its params. The reader keeps the params as raw JSON,
a jsontext.Value:
type incoming struct {
ID int `json:"id"`
Method string `json:"method"`
Params jsontext.Value `json:"params"`
// ...
}
Each subscriber decodes the params into its own type. A decode into
any followed by a marshal reads each message twice, and builds a map
for each JSON object in between.
The cheap check first
My assertion package, is, compares two
values in Eq. It first rejects two values of different types. For a
type with no interface field and no blank field, it then compares with
==. The other types go to reflect. The package caches the verdict
for each struct type.
When an assertion fails, is reads the test file to label the value.
The parse walks only the AST nodes whose line range covers the failing
line.
Rules for a faster path
A faster path gives the same output as the slow one. The change runs the existing tests, and a large fixture compares the output byte for byte. Where a fast path covers some types and a fallback covers the rest, a test compares the two.
A benchmark result does not change a limit. A depth check, a size check, and an escape stay as they are. The benchmark tells me what each one costs.
Assertions in these benchmarks and tests come from is. See
go / test. The checks that run on each push are in
go / checks.