benchmarks-go-testing

Benchmarks in Go with go test -bench and Reliable Measurement

  • 3 min

A benchmark in Go is an automated test that measures how long a piece of code takes to execute.

It is not for proving that something works. For that we have tests. A benchmark answers a different question: how much does this cost?

Go includes benchmark support in the same testing package, so we can measure performance without installing external tools.

Creating a Benchmark

Suppose we have this function:

package texto

func Repetir(s string, n int) string {
    resultado := ""

    for i := 0; i < n; i++ {
        resultado += s
    }

    return resultado
}
Copied!

We create a file texto_test.go:

package texto

import "testing"

func BenchmarkRepetir(b *testing.B) {
    for b.Loop() {
        Repetir("go", 100)
    }
}
Copied!

The function starts with Benchmark and receives b *testing.B.

The Benchmark Loop

Since Go 1.24, the recommended approach is to use b.Loop().

for b.Loop() {
    // Code we want to measure
}
Copied!

Go runs the loop as many times as needed to reach the measurement time. b.Loop() automatically excludes setup and cleanup code placed outside the loop, prevents relying on the current iteration, and keeps the arguments and return values of calls within the body alive.

You will also see many older examples using b.N:

for i := 0; i < b.N; i++ {
    // Code we want to measure
}
Copied!

That pattern still works, but for new code it’s better to use b.Loop().

Running Benchmarks

We use:

go test -bench=.
Copied!

Typical output:

BenchmarkRepetir-12     120000      9800 ns/op
PASS
Copied!

The final number (ns/op) indicates nanoseconds per operation. Lower is better, as long as we are measuring the right thing.

Preventing the Compiler from Eliminating Work

In a classic benchmark, if the result is not used, the compiler might eliminate some of the work.

With b.Loop(), arguments, return values, and variables assigned inside the body are kept alive, as long as the condition is written exactly as b.Loop(). Therefore, we don’t need a global variable just to prevent result elimination:

func BenchmarkRepetir(b *testing.B) {
    for b.Loop() {
        Repetir("go", 100)
    }
}
Copied!

Setup Inside and Outside the Loop

Many times we need to prepare data before measuring.

func BenchmarkProcesar(b *testing.B) {
    datos := cargarDatosDePrueba()

    for b.Loop() {
        Procesar(datos)
    }
}
Copied!

With b.Loop(), the preceding setup is excluded from the measurement. If we need to prepare data in each iteration, we can wrap that part with b.StopTimer() and b.StartTimer(). b.ResetTimer() still appears in classic benchmarks with b.N, but is not needed in this example.

Measuring Memory Allocations

To see allocations:

go test -bench=. -benchmem
Copied!

Output:

BenchmarkRepetir-12     120000      9800 ns/op     5300 B/op     99 allocs/op
Copied!

This tells us:

  • B/op: bytes allocated per operation.
  • allocs/op: number of allocations per operation.

In Go, many interesting optimizations involve reducing allocations, not just saving nanoseconds.

Comparing Two Implementations

We can write another benchmark using strings.Builder, which is more suitable for concatenating many pieces.

func RepetirBuilder(s string, n int) string {
    var builder strings.Builder

    for i := 0; i < n; i++ {
        builder.WriteString(s)
    }

    return builder.String()
}

func BenchmarkRepetirBuilder(b *testing.B) {
    for b.Loop() {
        RepetirBuilder("go", 100)
    }
}
Copied!

Now we run:

go test -bench=. -benchmem
Copied!

A single run can be affected by CPU frequency, temperature, and system processes. For a serious comparison, we run multiple samples and use benchstat:

go test -run '^$' -bench=. -benchmem -count=10 > resultados.txt
benchstat resultados.txt
Copied!

benchstat is part of golang.org/x/perf/cmd/benchstat. The important thing is not that one version appears faster, but having repeated data for our specific case.