Benchmarks en Go: cómo medir antes de optimizar
Benchmarks en Go con testing.B: cómo medir rendimiento, evitar errores de medición y tomar decisiones prácticas con datos.

Una vez perdí dos días optimizando una función que no era el cuello de botella. Reescribí el parseo de un JSON grande con unsafe pointers, eliminé allocations, aplané structs anidados. El resultado fue un código ilegible que mejoraba el rendimiento de esa función un 40%. El problema es que esa función representaba el 2% del tiempo total de la request. Dos días de trabajo para una mejora imperceptible en producción.
Las herramientas de benchmarking de Go me habrían ahorrado esos dos días. Cinco minutos con testing.B y pprof habrían dejado claro que el cuello de botella real estaba en la conexión a la base de datos, no en el parseo. Pero preferí confiar en mi intuición, y mi intuición estaba equivocada.
Optimizar sin medir es una forma cara de confirmar prejuicios. Este artículo va de cómo medir antes de tocar nada.
La interfaz testing.B: cómo funcionan los benchmarks en Go
Si ya has escrito tests en Go, los benchmarks te van a resultar familiares. Viven en los mismos ficheros _test.go, usan el mismo paquete testing, y se ejecutan con go test. La diferencia es que en lugar de recibir *testing.T, reciben *testing.B.
La firma de un benchmark es siempre la misma:
func BenchmarkNombreDescriptivo(b *testing.B) {
for i := 0; i < b.N; i++ {
// código a medir
}
}El nombre empieza con Benchmark (obligatorio) seguido de una descripción en CamelCase. El parámetro b *testing.B te da acceso al framework de benchmarking.
Lo más importante aquí es b.N. No lo defines tú. Go lo ajusta automáticamente. El framework ejecuta tu benchmark varias veces, incrementando b.N en cada iteración, hasta que obtiene una medición estadísticamente estable. Puede ejecutar tu código 100 veces, 10.000 veces o 100.000.000 de veces. Tú no controlas ese número y no deberías intentar hacerlo.
Esto significa que lo que pongas dentro del bucle for i := 0; i < b.N; i++ tiene que ser una unidad completa de trabajo. No medio trabajo. No trabajo con efectos secundarios que se acumulen entre iteraciones. Una operación limpia, repetible, aislada.
Tu primer benchmark
Supongamos que tienes una función que concatena strings. Algo simple:
// concat.go
package concat
import "strings"
func ConcatPlus(parts []string) string {
result := ""
for _, p := range parts {
result += p
}
return result
}
func ConcatBuilder(parts []string) string {
var sb strings.Builder
for _, p := range parts {
sb.WriteString(p)
}
return sb.String()
}Dos implementaciones del mismo problema. La primera usa el operador +, que en Go crea un nuevo string en cada iteración (los strings son inmutables). La segunda usa strings.Builder, que acumula bytes en un buffer interno y genera el string final una sola vez.
Intuitivamente, strings.Builder debería ser más rápido. Pero “debería” no es un dato. Vamos a medir:
// concat_test.go
package concat
import "testing"
var parts = []string{
"benchmark", "en", "go", "es", "una", "herramienta",
"fundamental", "para", "medir", "rendimiento",
}
func BenchmarkConcatPlus(b *testing.B) {
for i := 0; i < b.N; i++ {
ConcatPlus(parts)
}
}
func BenchmarkConcatBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
ConcatBuilder(parts)
}
}Fíjate en que parts se define fuera del benchmark como variable de paquete. Esto es deliberado: no queremos medir el tiempo de crear el slice de entrada, solo el de la concatenación. Si lo definimos dentro del bucle, estaríamos midiendo ruido.
Ejecutar benchmarks: go test -bench
Los benchmarks no se ejecutan por defecto cuando haces go test. Necesitas el flag -bench:
go test -bench=. ./...El . después de -bench es una expresión regular que filtra qué benchmarks ejecutar. Un punto significa “todos”. Si solo quieres ejecutar uno:
go test -bench=BenchmarkConcatBuilder ./...O un patrón parcial:
go test -bench=Concat ./...Flags que vas a usar constantemente:
-bench=.ejecuta todos los benchmarks.-benchmemincluye estadísticas de memoria (allocations y bytes).-count=Nrepite el benchmark N veces para mejor significación estadística.-benchtime=5scambia la duración mínima de cada benchmark (por defecto 1 segundo).-cpu=1,2,4ejecuta los benchmarks con distintos valores deGOMAXPROCS.
Un comando completo y útil:
go test -bench=. -benchmem -count=5 ./...Esto ejecuta todos los benchmarks, reporta memoria, y repite cada uno cinco veces. Las cinco ejecuciones son importantes cuando quieras comparar después con benchstat.
Leer la salida: ns/op, B/op, allocs/op
Cuando ejecutas el benchmark de concatenación con -benchmem, obtienes algo así:
BenchmarkConcatPlus-8 1924562 617.3 ns/op 352 B/op 9 allocs/op
BenchmarkConcatBuilder-8 5765418 207.1 ns/op 120 B/op 2 allocs/opCada columna significa algo concreto:
- BenchmarkConcatPlus-8: el nombre del benchmark. El
-8indica que se ejecutó conGOMAXPROCS=8(8 núcleos lógicos). - 1924562: el valor de
b.N, es decir, cuántas veces ejecutó Go tu código para obtener una medición estable. - 617.3 ns/op: nanosegundos por operación. Esta es la métrica principal de rendimiento.
- 352 B/op: bytes de heap allocados por operación.
- 9 allocs/op: número de allocations de heap por operación.
Los datos confirman lo que esperábamos: strings.Builder es tres veces más rápido y usa mucha menos memoria. Pero ahora no es una intuición, es un dato con un número de iteraciones suficiente para ser fiable.
Qué importa y qué no
Los ns/op importan cuando el rendimiento es un requisito. Los B/op y allocs/op importan siempre, porque las allocations presionan al garbage collector, y el GC es una fuente constante de latencia en aplicaciones de Go que manejan mucha carga.
El valor de b.N no importa directamente. Es un artefacto del mecanismo de medición. Si un benchmark ejecuta 100 millones de iteraciones y otro ejecuta 1 millón, no significa que uno sea mejor. Significa que Go necesitó más iteraciones para estabilizar la medición del más rápido.
Comparar benchmarks: benchstat
Ejecutar un benchmark una vez te da un número. Ejecutarlo cinco veces y comparar con benchstat te da un dato estadístico con intervalos de confianza.
Instala benchstat si no lo tienes:
go install golang.org/x/perf/cmd/benchstat@latestEl flujo de trabajo es guardar la salida de tus benchmarks en ficheros y compararlos:
go test -bench=. -benchmem -count=10 ./... > old.txt
# Haces tus cambios en el código
go test -bench=. -benchmem -count=10 ./... > new.txt
benchstat old.txt new.txtLa salida de benchstat te da algo como:
goos: linux
goarch: amd64
pkg: example.com/concat
│ old.txt │ new.txt │
│ sec/op │ sec/op vs base │
ConcatPlus-8 617.0n ± 2% 210.5n ± 1% -65.88% (p=0.000)
ConcatBuilder-8 207.0n ± 1% 198.2n ± 1% -4.25% (p=0.001)El ± 2% es la variación entre ejecuciones. El p=0.000 indica que la diferencia es estadísticamente significativa (p < 0.05). Si benchstat te dice ~ (p=0.342), la diferencia no es significativa y no deberías tratarla como una mejora real.
Esto es fundamental. Un benchmark individual puede dar resultados distintos según la carga del sistema, la temperatura de la CPU, o lo que esté haciendo tu navegador de fondo. benchstat con -count=10 te protege contra eso.
Errores de medición habituales
El compilador te optimiza el código
Go tiene un compilador agresivo. Si el resultado de tu función no se usa en ningún sitio, el compilador puede eliminar la llamada entera. Tu benchmark mediría literalmente nada.
Mal:
func BenchmarkMal(b *testing.B) {
for i := 0; i < b.N; i++ {
ConcatBuilder(parts) // el resultado se descarta
}
}En muchos casos esto funciona porque el compilador no es tan agresivo con funciones complejas. Pero en funciones simples o puras, puede eliminarlo. La solución estándar es asignar el resultado a una variable de paquete:
var result string
func BenchmarkBien(b *testing.B) {
var r string
for i := 0; i < b.N; i++ {
r = ConcatBuilder(parts)
}
result = r
}La variable result es de paquete, así que el compilador no puede asumir que nadie la usa. Asignamos a r dentro del bucle para evitar una escritura a variable global en cada iteración (que sería un side effect medible) y copiamos al final.
b.ResetTimer: setup que no quieres medir
Si tu benchmark necesita setup costoso antes de medir, usa b.ResetTimer() para que ese setup no cuente:
func BenchmarkConSetup(b *testing.B) {
// Setup costoso: crear datos, abrir conexiones, etc.
data := generarDatosDePrueba(10000)
b.ResetTimer() // El cronómetro se reinicia aquí
for i := 0; i < b.N; i++ {
procesarDatos(data)
}
}Sin b.ResetTimer(), el tiempo de generarDatosDePrueba contaminaría tu medición. Especialmente problemático si ese setup tarda más que la función que quieres medir.
b.StopTimer y b.StartTimer: pausar entre iteraciones
A veces necesitas hacer trabajo entre iteraciones que no quieres medir. Por ejemplo, resetear un estado:
func BenchmarkConPausa(b *testing.B) {
for i := 0; i < b.N; i++ {
b.StopTimer()
data := generarDatosFrescos() // no se mide
b.StartTimer()
procesarDatos(data) // esto sí se mide
}
}Usa esto con cuidado. Llamar a b.StopTimer() y b.StartTimer() dentro del bucle tiene overhead propio. Si tu función es muy rápida (nanosegundos), ese overhead puede ser mayor que lo que mides. En esos casos, es mejor preparar todos los datos fuera del bucle.
Benchmarks que dependen del estado previo
Cada iteración debería ser independiente. Si tu función modifica un estado compartido entre iteraciones, las mediciones se contaminan:
// MAL: el map crece en cada iteración
func BenchmarkMapAppend(b *testing.B) {
m := make(map[string]int)
for i := 0; i < b.N; i++ {
m[fmt.Sprintf("key-%d", i)] = i // cada iteración más lenta
}
}Aquí, las últimas iteraciones son más lentas que las primeras porque el map es más grande. El benchmark te da un promedio que no representa ningún caso real.
Benchmarks de memoria: b.ReportAllocs
Ya has visto que -benchmem te da estadísticas de memoria. También puedes activar el reporte de allocations desde el propio benchmark con b.ReportAllocs():
func BenchmarkConReportAllocs(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
ConcatPlus(parts)
}
}Esto es útil cuando quieres que las allocations siempre se reporten para un benchmark concreto, sin depender de que quien lo ejecute recuerde poner -benchmem.
Por qué las allocations importan
En Go, cada allocation de heap es trabajo futuro para el garbage collector. Menos allocations significa menos presión sobre el GC, lo que se traduce en menor latencia y menos pausas. En aplicaciones de alta carga, como las que construirías si estás usando Go para tareas pesadas, la diferencia entre 0 allocations y 3 allocations por operación puede ser la diferencia entre p99 de 5ms y p99 de 50ms.
Una buena práctica es empezar midiendo allocations antes de mirar los nanosegundos. Si una función hace 10 allocations por llamada y la llamas 100.000 veces por segundo, eso es un millón de allocations por segundo que el GC tiene que gestionar.
Pre-alocar para reducir allocations
Una optimización común que los benchmarks revelan es la pre-alocación de slices y maps:
func BenchmarkSliceSinPrealocar(b *testing.B) {
for i := 0; i < b.N; i++ {
s := []int{}
for j := 0; j < 1000; j++ {
s = append(s, j)
}
}
}
func BenchmarkSlicePrealojado(b *testing.B) {
for i := 0; i < b.N; i++ {
s := make([]int, 0, 1000)
for j := 0; j < 1000; j++ {
s = append(s, j)
}
}
}Ejecuta esto con -benchmem y verás que la versión sin pre-alocar hace entre 10 y 20 allocations (cada vez que el slice crece, Go aloca un backing array más grande). La versión pre-alocada hace una sola allocation.
Profiling con pprof: lo básico
Los benchmarks te dicen cuánto tarda algo. pprof te dice dónde se gasta el tiempo. Son herramientas complementarias.
Para generar un perfil de CPU desde tus benchmarks:
go test -bench=BenchmarkConcatPlus -cpuprofile=cpu.out ./...Esto genera un fichero cpu.out que puedes analizar con go tool pprof:
go tool pprof cpu.outDentro de pprof, los comandos más útiles:
(pprof) top10 # las 10 funciones que más CPU consumen
(pprof) list ConcatPlus # muestra el código fuente anotado con tiempos
(pprof) web # abre un grafo interactivo en el navegadorPara perfiles de memoria:
go test -bench=BenchmarkConcatPlus -memprofile=mem.out ./...
go tool pprof mem.outDentro del perfil de memoria, top10 te muestra qué funciones alocan más memoria, y list te señala las líneas exactas.
El flujo completo
El flujo pragmático para optimizar rendimiento en Go es:
- Benchmark: identifica cuánto tarda cada operación.
- pprof CPU: identifica dónde se gasta el tiempo.
- pprof memoria: identifica qué está alocando.
- Optimiza: cambia solo lo que los datos te dicen que importa.
- Benchmark de nuevo: verifica que tu cambio realmente mejoró algo.
- benchstat: confirma que la mejora es estadísticamente significativa.
Si te saltas el paso 1 y vas directo a optimizar, estás donde yo estaba hace unos años: perdiendo dos días en algo que no importa.
Cuándo hacer benchmarks y cuándo no
Sí
- Cuando estás eligiendo entre dos implementaciones y el rendimiento es un factor.
- Antes de optimizar cualquier cosa. Mide primero.
- Cuando un cambio toca código en el hot path de tu aplicación.
- Para validar que una optimización realmente mejoró algo.
- En el CI, para detectar regresiones de rendimiento entre commits.
No
- Para código que se ejecuta una vez al arrancar la aplicación.
- Para endpoints que hacen I/O (HTTP, base de datos): el benchmark no captura la latencia de red.
- Como sustituto de un load test. Los benchmarks miden funciones aisladas. Un load test mide el sistema completo.
- Cuando la diferencia entre ambas opciones es de nanosegundos y tu aplicación maneja 10 requests por segundo. La legibilidad importa más.
La regla general: si no puedes articular por qué el rendimiento de esa función específica es crítico, probablemente no necesitas un benchmark. Y si lo necesitas, los datos te lo dirán rápido.
Ejemplo práctico: comparar dos implementaciones
Vamos a un caso más realista. Supongamos que tienes un servicio que filtra usuarios por un conjunto de roles. Dos enfoques:
// users.go
package users
type User struct {
Name string
Roles []string
}
// FiltrarConSlice recorre los roles de cada usuario con un slice.
func FiltrarConSlice(users []User, rolesPermitidos []string) []User {
var resultado []User
for _, u := range users {
for _, rol := range u.Roles {
if contieneSlice(rolesPermitidos, rol) {
resultado = append(resultado, u)
break
}
}
}
return resultado
}
func contieneSlice(s []string, val string) bool {
for _, v := range s {
if v == val {
return true
}
}
return false
}
// FiltrarConMap convierte los roles permitidos a un map para búsqueda O(1).
func FiltrarConMap(users []User, rolesPermitidos []string) []User {
permitidos := make(map[string]struct{}, len(rolesPermitidos))
for _, r := range rolesPermitidos {
permitidos[r] = struct{}{}
}
var resultado []User
for _, u := range users {
for _, rol := range u.Roles {
if _, ok := permitidos[rol]; ok {
resultado = append(resultado, u)
break
}
}
}
return resultado
}La primera implementación es O(nmk) donde n=usuarios, m=roles por usuario, k=roles permitidos. La segunda es O(n*m) porque la búsqueda en el map es O(1). En teoría, el map debería ganar. Pero el map tiene overhead de creación y hashing. Con pocos roles permitidos, el slice podría ser más rápido.
El benchmark:
// users_test.go
package users
import "testing"
func generarUsuarios(n int) []User {
users := make([]User, n)
roles := []string{"admin", "editor", "viewer", "moderator", "guest"}
for i := range users {
users[i] = User{
Name: "user",
Roles: []string{roles[i%len(roles)], roles[(i+1)%len(roles)]},
}
}
return users
}
var rolesPermitidos = []string{"admin", "editor", "moderator"}
var resultadoSink []User
func BenchmarkFiltrarConSlice_100(b *testing.B) {
users := generarUsuarios(100)
b.ResetTimer()
for i := 0; i < b.N; i++ {
resultadoSink = FiltrarConSlice(users, rolesPermitidos)
}
}
func BenchmarkFiltrarConMap_100(b *testing.B) {
users := generarUsuarios(100)
b.ResetTimer()
for i := 0; i < b.N; i++ {
resultadoSink = FiltrarConMap(users, rolesPermitidos)
}
}
func BenchmarkFiltrarConSlice_10000(b *testing.B) {
users := generarUsuarios(10000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
resultadoSink = FiltrarConSlice(users, rolesPermitidos)
}
}
func BenchmarkFiltrarConMap_10000(b *testing.B) {
users := generarUsuarios(10000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
resultadoSink = FiltrarConMap(users, rolesPermitidos)
}
}Fíjate en varios detalles:
- Generamos los datos fuera del bucle y usamos
b.ResetTimer()para no medir la generación. - Asignamos a
resultadoSink(variable de paquete) para evitar que el compilador elimine la llamada. - Probamos con dos tamaños (100 y 10.000) porque el comportamiento puede cambiar con el volumen de datos.
Con 3 roles permitidos, es posible que la versión con slice gane en el caso de 100 usuarios (el overhead del map no compensa). Y que la versión con map gane con 10.000 usuarios. O quizá no. Ese es exactamente el punto: el benchmark te da la respuesta en vez de obligarte a adivinar.
Si los resultados muestran que la diferencia es marginal, elige la implementación más legible. Si una es un 10x más rápida con tu volumen de datos real, elige la más rápida. Pero deja que los números decidan.
Sub-benchmarks para parametrizar
Go permite sub-benchmarks con b.Run, que son ideales para probar múltiples tamaños sin duplicar funciones:
func BenchmarkFiltrarConSlice(b *testing.B) {
for _, size := range []int{10, 100, 1000, 10000} {
users := generarUsuarios(size)
b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
for i := 0; i < b.N; i++ {
resultadoSink = FiltrarConSlice(users, rolesPermitidos)
}
})
}
}La salida será:
BenchmarkFiltrarConSlice/size=10-8 ...
BenchmarkFiltrarConSlice/size=100-8 ...
BenchmarkFiltrarConSlice/size=1000-8 ...
BenchmarkFiltrarConSlice/size=10000-8 ...Puedes filtrar sub-benchmarks específicos:
go test -bench=FiltrarConSlice/size=1000 ./...Esto hace los benchmarks mucho más mantenibles. En lugar de escribir una función por caso, parametrizas y dejas que el framework haga el trabajo.
Benchmarks paralelos: b.RunParallel
Si tu código se ejecuta en un contexto concurrente (y en Go, casi todo lo hace), puedes medir rendimiento bajo concurrencia con b.RunParallel:
func BenchmarkFiltrarConcurrente(b *testing.B) {
users := generarUsuarios(1000)
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
FiltrarConMap(users, rolesPermitidos)
}
})
}b.RunParallel lanza múltiples goroutines que ejecutan tu código en paralelo. Cada goroutine itera llamando a pb.Next() en lugar de usar b.N directamente. El framework se encarga de distribuir las iteraciones.
Esto es útil para detectar contención: locks, atomic operations, false sharing en caché. Una función que es rápida en un benchmark secuencial puede ser lenta cuando diez goroutines la ejecutan simultáneamente.
Medir primero, optimizar después
Todo lo que hemos visto se reduce a una idea: no optimices basándote en lo que crees que es lento. Mide, confirma y solo entonces actúa.
Go te da las herramientas integradas en la librería estándar. No necesitas frameworks externos, ni configuración especial, ni licencias. testing.B está ahí desde el primer día, listo para usar en cualquier fichero _test.go.
El flujo resumido:
- Escribe un benchmark para la función que sospechas que es lenta.
- Ejecuta con
-benchmem -count=5para tener datos de CPU y memoria. - Usa
benchstatpara comparar antes y después con significación estadística. - Genera perfiles con
-cpuprofiley-memprofilesi necesitas saber exactamente dónde se gasta el tiempo. - Optimiza solo lo que los datos señalen como cuello de botella real.
- Mide de nuevo para confirmar que tu cambio mejoró algo.
Si el benchmark te dice que la diferencia entre dos implementaciones es de 20 nanosegundos y tu endpoint tarda 200 milisegundos, cierra el benchmark y dedica tu tiempo a algo que importe. Si te dice que una implementación aloca 50 veces menos memoria en un hot path que procesa 100.000 requests por segundo, has encontrado algo que vale la pena optimizar.
Los datos no tienen opiniones. Tu intuición sí. Confía en los datos.


