Medir el rendimiento con benchmarks en Go

Go trae un banco de pruebas de serie: se escribe en el mismo archivo que las pruebas y se ejecuta con go test -bench. No hay que instalar nada, y la diferencia entre creer que algo es rápido y saberlo son unas diez líneas.

Un benchmark, entero

Va en un archivo algo_test.go, se llama BenchmarkLoQueSea y recibe un *testing.B:

func BenchmarkConBuilder(b *testing.B) {
	b.ReportAllocs()
	for b.Loop() {
		ConBuilder(ps)
	}
}
go test -run XXX -bench . -benchmem
-run XXX es un truco corriente: filtra las pruebas normales por un nombre que no existe, para que solo se ejecuten los benchmarks. Sin él, go test corre primero todas las pruebas. Y -benchmem añade las dos columnas de memoria, que casi siempre son las interesantes.

Cuatro formas de pegar cadenas

El caso de manual: unir 200 trozos de texto. Las cuatro funciones hacen exactamente lo mismo —hay una prueba que lo comprueba— y la única diferencia es cómo.

func ConMas(ps []string) string {
	s := ""
	for _, p := range ps {
		s += p
	}
	return s
}

func ConBuilder(ps []string) string {
	var b strings.Builder
	for _, p := range ps {
		b.WriteString(p)
	}
	return b.String()
}

func ConBuilderReservado(ps []string) string {
	n := 0
	for _, p := range ps {
		n += len(p)
	}
	var b strings.Builder
	b.Grow(n)                      // reserva de una vez lo que va a hacer falta
	for _, p := range ps {
		b.WriteString(p)
	}
	return b.String()
}

func ConJoin(ps []string) string { return strings.Join(ps, "") }
salidagoos: linux
goarch: amd64
pkg: ej/unir
BenchmarkConMas-16                    28467   43107 ns/op   213609 B/op   199 allocs/op
BenchmarkConBuilder-16               460201    2342 ns/op     5360 B/op     9 allocs/op
BenchmarkConBuilderReservado-16      947893    1242 ns/op     2048 B/op     1 allocs/op
BenchmarkConJoin-16                  558753    2067 ns/op     2048 B/op     1 allocs/op

Cómo se lee esa tabla

Columna Qué es
-16 en el nombre El valor de GOMAXPROCS, los núcleos disponibles
28467 Cuántas veces se ejecutó. Lo decide Go, no tú
43107 ns/op Nanosegundos por ejecución. La columna principal
213609 B/op Bytes reservados en el montón por ejecución
199 allocs/op Cuántas reservas distintas. Suele predecir el resto

El += es 35 veces más lento y hace 199 reservas de memoria, una por vuelta: las cadenas en Go son inmutables, así que cada s += p crea una cadena nueva y copia todo lo anterior. Es el clásico bucle que parece lineal y es cuadrático.

Y fíjate en el detalle que de verdad se aprende midiendo: Grow baja de 9 reservas a 1 y casi dobla la velocidad frente al Builder normal. Si sabes cuánto vas a escribir, decírselo sale gratis.

Tres de las cuatro formas están bien. La conclusión práctica no es «usa siempre Builder», es: strings.Join cuando los trozos ya están en un slice, strings.Builder cuando se van generando, y += solo fuera de un bucle.

Varios tamaños de golpe

func BenchmarkPorTamano(b *testing.B) {
	for _, n := range []int{10, 100, 1000} {
		b.Run(fmt.Sprintf("builder-%d", n), func(b *testing.B) {
			p := piezas(n)
			b.ReportAllocs()
			for b.Loop() {
				ConBuilder(p)
			}
		})
	}
}
salidaBenchmarkPorTamano/builder-10-16     6653050    177.3 ns/op     240 B/op    4 allocs/op
BenchmarkPorTamano/builder-100-16     969493   1457   ns/op    3312 B/op    8 allocs/op
BenchmarkPorTamano/builder-1000-16     91130  13291   ns/op   46576 B/op   15 allocs/op

Diez veces más datos, diez veces más tiempo: la función escala de forma lineal, que era lo que había que comprobar. Las reservas crecen de 4 a 15 y no de 10 en 10 porque el Builder dobla su capacidad cada vez que se queda corto.

Esto es, con diferencia, el uso más valioso de los benchmarks: no comparar dos funciones, sino ver cómo crece una con el tamaño de la entrada.

b.Loop, y por qué ya no hace falta el truco del sumidero

Hasta Go 1.23 los benchmarks se escribían así:

for i := 0; i < b.N; i++ {
	suma(1000)
}

Y había un consejo repetido en todas partes: guarda el resultado en una variable global, porque si no el compilador puede ver que nadie lo usa y eliminar la llamada entera, con lo que medirías un bucle vacío.

Desde Go 1.24 existe b.Loop(), que además de contar las vueltas mantiene vivos los argumentos y el resultado de la función que se llama dentro. Comprobado con dos benchmarks idénticos salvo por el sumidero:

salidaBenchmarkSumaMal-16     4088487    291.3 ns/op
BenchmarkSumaBien-16    3912202    300.9 ns/op

La misma cifra dentro del ruido. Con b.Loop() el truco sobra. Aun así vas a seguir viéndolo en código existente, y conviene reconocerlo.

Lo que b.Loop no arregla es la preparación. Si antes del bucle construyes los datos de entrada, eso también se mide. Para excluirlo: b.ResetTimer() justo antes del bucle, o b.StopTimer() y b.StartTimer() alrededor de la parte que no cuenta.

No te fíes de una sola medida

El mismo benchmark, tres veces seguidas, en la misma máquina:

go test -run XXX -bench ConBuilder$ -count=3
salidaBenchmarkConBuilder-16    732578    1651 ns/op
BenchmarkConBuilder-16    732102    2281 ns/op
BenchmarkConBuilder-16    517873    2281 ns/op

Un 38 % de diferencia entre la primera y las otras dos, sin cambiar una línea. Otros procesos, la frecuencia del procesador, el recolector de basura. Una diferencia del 10 % entre dos funciones no significa nada si no has repetido la medición.

Para comparar en serio está benchstat, que toma varias ejecuciones de cada versión y dice si la diferencia es estadísticamente significativa:

go install golang.org/x/perf/cmd/benchstat@latest

go test -run XXX -bench . -count=10 > antes.txt
# … cambias el código …
go test -run XXX -bench . -count=10 > despues.txt

benchstat antes.txt despues.txt

Antes de optimizar nada

Un benchmark dice cuánto tarda algo, no dónde se va el tiempo de tu programa. Para eso está el perfilador, que sale del mismo go test:

go test -run XXX -bench ConMas -cpuprofile cpu.out -memprofile mem.out
go tool pprof -top cpu.out

Y la regla de siempre: mide primero. La función que crees que es el cuello de botella casi nunca lo es, y el 90 % del tiempo suele estar en el 1 % del código.

Resumen

Para Orden o función
Ejecutar solo los benchmarks go test -run XXX -bench .
Ver la memoria -benchmem o b.ReportAllocs()
Uno concreto -bench ConBuilder$
Repetir la medición -count=10
Alargar cada medición -benchtime=5s o -benchtime=1000x
Varios tamaños b.Run(nombre, func(b *testing.B){…})
No medir la preparación b.ResetTimer()
Comparar dos versiones benchstat antes.txt despues.txt
Saber dónde se va el tiempo -cpuprofile + go tool pprof

Todo el código se compiló y ejecutó con Go 1.25 antes de publicarla; los números son los reales de esa ejecución, en una sola máquina y sin ninguna pretensión de ser universales. Documentación oficial: testing.B.