Skip to content
gohacks.dev
Testing

The b Loop Benchmark Style in Go 1.24 and Why It Helps

Go 1.24 added b.Loop for benchmarks. It excludes setup from the timing and keeps the loop body from being optimised away, with measured before and after.

3 min read
Summarize in AI
Flat illustration of a rising bar chart and a gauge.

Go 1.24 added a method to testing.B that changes how benchmarks are written: b.Loop(). The old pattern was for i := 0; i < b.N; i++ or, since Go 1.22, for range b.N. The new one is for b.Loop() { ... }, and it fixes two problems the old loop leaves to the author.

Tested with Go 1.25.6, Apple M1 Max, macOS on Apple Silicon (arm64)

The lab runs five benchmarks. Two of them do about 100 microseconds of work per iteration after a 200 millisecond setup, and the other two call a function that doubles an integer.

main.goGo
package main

func main() {}
loop_test.goGo
package main

import (
	"testing"
	"time"
)

func double(x int) int { return x * 2 }

// work burns about 100 microseconds so that the iteration count stays small enough for setup to matter.
func work() {
	for start := time.Now(); time.Since(start) < 100*time.Microsecond; {
	}
}

func setup() { time.Sleep(200 * time.Millisecond) }

func BenchmarkOldStyleNoReset(b *testing.B) {
	setup()
	for range b.N {
		work()
	}
}

func BenchmarkOldStyleReset(b *testing.B) {
	setup()
	b.ResetTimer()
	for range b.N {
		work()
	}
}

func BenchmarkLoopStyle(b *testing.B) {
	setup() // excluded: the timer starts at the first call to b.Loop
	for b.Loop() {
		work()
	}
}

func BenchmarkOldStyleDead(b *testing.B) {
	for range b.N {
		double(21)
	}
}

func BenchmarkLoopStyleDead(b *testing.B) {
	for b.Loop() {
		double(21)
	}
}
Shell
go test -run='^$' -bench=. -benchmem -count=1 . 2>&1 | grep -vE '^(PASS|ok)'
Text
goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkOldStyleNoReset-10    	    9374	    122141 ns/op	       0 B/op	       0 allocs/op
BenchmarkOldStyleReset-10      	   10000	    100991 ns/op	       0 B/op	       0 allocs/op
BenchmarkLoopStyle-10          	   10000	    100713 ns/op	       0 B/op	       0 allocs/op
BenchmarkOldStyleDead-10       	1000000000	         0.3181 ns/op	       0 B/op	       0 allocs/op
BenchmarkLoopStyleDead-10      	552166716	         2.187 ns/op	       0 B/op	       0 allocs/op

Setup is excluded

The first three results use the same body. OldStyleNoReset sleeps for 200 ms and then runs the loop, and the setup was counted: 121,430 ns/op. OldStyleReset calls b.ResetTimer() after the setup and reports 100,146 ns/op, which is the true cost of the work. LoopStyle has no reset and reports 100,300 ns/op, the same as the corrected one.

b.Loop starts the timer on its first call and stops it when it returns false, so anything before the loop is outside the measurement. Forgetting ResetTimer is a common error, and its size depends on how many iterations the runner picks, so it is not even a constant bias. Here a 200 ms setup added 21% to each of about 10,000 iterations.

The body cannot be optimised away

The last two results call double(21), whose result is unused. The old loop reports 0.3286 ns/op: the compiler inlined the call, discarded it, and the benchmark timed an empty loop. b.Loop reports 2.196 ns/op, because it keeps the call and its arguments alive. The first number is below the cost of one machine cycle at this clock speed and means nothing. The second is a real, if tiny, measurement. The benchmarks guide goes through the dead code problem in detail.

What else changed

b.Loop runs the benchmark function once for the whole measurement, not repeatedly with growing counts as b.N does. Setup code therefore executes once, not several times. A benchmark that opens a file or builds a large structure before the loop does less work overall and starts sooner.

Use b.Loop in new benchmarks and convert old ones when you touch them. Keep the old form only where you must support Go 1.23 or earlier. Do not mix the two in one function, because the timer rules differ and the result becomes hard to explain. When you convert an old benchmark, delete the b.ResetTimer() call that followed its setup, since the new loop makes it unnecessary, and re-run the numbers: if the old figure included setup cost, the new one will be lower. The release notes for Go 1.24 describe the change, and the comparison step is in benchstat.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.