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.
También te puede interesar
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.
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.