Cuando ejecutas go test -bench, el resultado te entrega la firma de rendimiento de tu código. Verás algo como 800 ns/op 3 allocs/op 96 B/op. Esto significa que cada operación tarda 800 nanosegundos en promedio, genera 3 asignaciones en el heap y consume 96 bytes de memoria. El runtime de Go ejecuta el bucle b.N veces, aumentando b.N dinámicamente hasta que el tiempo de ejecución sea suficiente para ser estadísticamente estable. Debes prestar especial atención a las allocs/op, ya que en Go, el costo real de una función en sistemas de alta carga no suele ser el tiempo de CPU, sino la presión sobre el Garbage Collector (GC); cada asignación al heap aumenta la probabilidad de que el GC se active, disparando la latencia de cola (P99) de tu aplicación. Usa benchmarks cuando estés optimizando un hot path o validando una refactorización crítica. Si solo ejecutas el benchmark una vez (-count=1), el ruido del sistema operativo (context switching, interrupciones) te dará datos erráticos; usa siempre -count=5 o más y utiliza benchstat para comparar resultados; esta herramienta calcula si la diferencia entre dos versiones es estadísticamente significativa mediante un p-value.
package example_test
import "testing"
// sink es una variable global necesaria para evitar que el compilador
// aplique "Dead Code Elimination" al detectar que el resultado de la función
// no se utiliza para nada que afecte al estado del programa.
var sink int
var inputData = []int{10, 20, 30, 40, 50, 60, 70, 80, 90, 100}
// NaiveProcess simula una función que escapa al heap en cada llamada
// al crear un nuevo slice mediante make.
func NaiveProcess(in []int) []int {
out := make([]int, len(in))
for i, v := range in {
out[i] = v * 2
}
return out
}
// OptimizedProcess evita asignaciones al reutilizar un buffer
// que el llamador ya tiene asignado en memoria.
func OptimizedProcess(in []int, buffer []int) []int {
for i, v := range in {
buffer[i] = v * 2
}
return buffer[:len(in)]
}
func BenchmarkNaive(b *testing.B) {
for i := 0; i < b.N; i++ {
res := NaiveProcess(inputData)
sink = res[0] // Aseguramos que el resultado sea "útil" para el compilador
}
}
func BenchmarkOptimized(b *testing.B) {
// Preparamos el buffer fuera del bucle de medición para no incluir
// su asignación inicial en los resultados del benchmark.
buffer := make([]int, len(inputData))
// ResetTimer descarta el tiempo transcurrido durante la preparación.
b.ResetTimer()
for i := 0; i < b.N; i++ {
res := OptimizedProcess(inputData, buffer)
sink = res[0]
}
}
En BenchmarkNaive, la función NaiveProcess ejecuta un make en cada iteración del bucle. Esto obliga al runtime a buscar memoria nueva en el heap, lo que se traduce en allocs/op > 0. En BenchmarkOptimized, hemos movido la asignación del buffer fuera del bucle de medición. Es vital usar b.ResetTimer() en este caso para que el tiempo de creación del buffer no contamine los resultados. Gracias a esto, BenchmarkOptimized mostrará 0 allocs/op, lo que indica una reducción drástica en la presión sobre el GC. Fíjate en la variable sink: si no asignáramos el resultado de las funciones a una variable global, el compilador, al notar que el retorno de la función no se usa para nada, podría decidir que la llamada es código muerto y eliminarla por completo.
El error frecuente
Si al ejecutar tus benchmarks obtienes un resultado de 0 ns/op, lo más probable es que no hayas encontrado el algoritmo perfecto, sino que el compilador ha aplicado Dead Code Elimination. Si una función no tiene efectos secundarios visibles y su resultado no se utiliza para algo que afecte al estado global, el compilador la eliminará del binario. Esto hace que el benchmark parezca increíblemente rápido, cuando en realidad no estás midiendo nada.
N° 162