Benchmarks en Go: com mesurar abans d'optimitzar

Benchmarks en Go amb testing.B: com mesurar rendiment, evitar errors de mesura i prendre decisions pràctiques amb dades.

Cover for Benchmarks en Go: com mesurar abans d'optimitzar

Una vegada vaig perdre dos dies optimitzant una funció que no era el coll d’ampolla. Vaig reescriure el parseo d’un JSON gran amb unsafe pointers, vaig eliminar allocations, vaig aplanar structs niuats. El resultat va ser un codi il·legible que millorava el rendiment d’aquella funció un 40%. El problema és que aquella funció representava el 2% del temps total de la request. Dos dies de feina per a una millora imperceptible en producció.

Les eines de benchmarking de Go m’haurien estalviat aquells dos dies. Cinc minuts amb testing.B i pprof haurien deixat clar que el coll d’ampolla real estava en la connexió a la base de dades, no en el parseo. Però vaig preferir confiar en la meva intuïció, i la meva intuïció estava equivocada.

Optimitzar sense mesurar és una forma cara de confirmar prejudicis. Aquest article va de com mesurar abans de tocar res.


La interfície testing.B: com funcionen els benchmarks en Go

Si ja has escrit tests en Go, els benchmarks et resultaran familiars. Viuen en els mateixos fitxers _test.go, usen el mateix paquet testing, i s’executen amb go test. La diferència és que en lloc de rebre *testing.T, reben *testing.B.

La signatura d’un benchmark és sempre la mateixa:

func BenchmarkNomDescriptiu(b *testing.B) {
    for i := 0; i < b.N; i++ {
        // codi a mesurar
    }
}

El nom comença amb Benchmark (obligatori) seguit d’una descripció en CamelCase. El paràmetre b *testing.B et dóna accés al framework de benchmarking.

El més important aquí és b.N. No el defineixes tu. Go l’ajusta automàticament. El framework executa el teu benchmark diverses vegades, incrementant b.N en cada iteració, fins que obté una mesura estadísticament estable. Pot executar el teu codi 100 vegades, 10.000 vegades o 100.000.000 de vegades. Tu no controles aquell número i no hauries d’intentar fer-ho.

Això significa que el que posis dins del bucle for i := 0; i < b.N; i++ ha de ser una unitat completa de treball. No mig treball. No treball amb efectes secundaris que s’acumulin entre iteracions. Una operació neta, repetible, aïllada.


El teu primer benchmark

Suposem que tens una funció que concatena strings. Alguna cosa 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()
}

Dues implementacions del mateix problema. La primera usa l’operador +, que en Go crea un nou string en cada iteració (els strings són immutables). La segona usa strings.Builder, que acumula bytes en un buffer intern i genera el string final una sola vegada.

Intuïtivament, strings.Builder hauria de ser més ràpid. Però “hauria” no és un dada. Anem a mesurar:

// concat_test.go
package concat

import \"testing\"

var parts = []string{
    \"benchmark\", \"en\", \"go\", \"és\", \"una\", \"eina\",
    \"fonamental\", \"per\", \"mesurar\", \"rendiment\",
}

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)
    }
}

Fixa’t que parts es defineix fora del benchmark com a variable de paquet. Això és deliberat: no volem mesurar el temps de crear el slice d’entrada, només el de la concatenació. Si ho definim dins del bucle, estaríem mesurant soroll.


Executar benchmarks: go test -bench

Els benchmarks no s’executen per defecte quan fas go test. Necessites el flag -bench:

go test -bench=. ./...

El . després de -bench és una expressió regular que filtra quins benchmarks executar. Un punt significa “tots”. Si només vols executar-ne un:

go test -bench=BenchmarkConcatBuilder ./...

O un patró parcial:

go test -bench=Concat ./...

Flags que usaràs constantment:

  • -bench=. executa tots els benchmarks.
  • -benchmem inclou estadístiques de memòria (allocations i bytes).
  • -count=N repeteix el benchmark N vegades per a millor significació estadística.
  • -benchtime=5s canvia la durada mínima de cada benchmark (per defecte 1 segon).
  • -cpu=1,2,4 executa els benchmarks amb diferents valors de GOMAXPROCS.

Una comanda completa i útil:

go test -bench=. -benchmem -count=5 ./...

Això executa tots els benchmarks, reporta memòria, i repeteix cada un cinc vegades. Les cinc execucions són importants quan vulguis comparar després amb benchstat.


Llegir la sortida: ns/op, B/op, allocs/op

Quan executes el benchmark de concatenació amb -benchmem, obtens alguna cosa així:

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/op

Cada columna significa alguna cosa concreta:

  • BenchmarkConcatPlus-8: el nom del benchmark. El -8 indica que s’ha executat amb GOMAXPROCS=8 (8 nuclis lògics).
  • 1924562: el valor de b.N, és a dir, quantes vegades Go ha executat el teu codi per obtenir una mesura estable.
  • 617.3 ns/op: nanosegons per operació. Aquesta és la mètrica principal de rendiment.
  • 352 B/op: bytes de heap alocats per operació.
  • 9 allocs/op: nombre d’allocations de heap per operació.

Les dades confirmen el que esperàvem: strings.Builder és tres vegades més ràpid i usa molta menys memòria. Però ara no és una intuïció, és una dada amb un nombre d’iteracions suficient per ser fiable.

Què importa i què no

Els ns/op importen quan el rendiment és un requisit. Els B/op i allocs/op importen sempre, perquè les allocations pressionen el garbage collector, i el GC és una font constant de latència en aplicacions de Go que gestionen molta càrrega.

El valor de b.N no importa directament. És un artefacte del mecanisme de mesura. Si un benchmark executa 100 milions d’iteracions i un altre executa 1 milió, no significa que un sigui millor. Significa que Go va necessitar més iteracions per estabilitzar la mesura del més ràpid.


Comparar benchmarks: benchstat

Executar un benchmark una vegada et dóna un número. Executar-lo cinc vegades i comparar amb benchstat et dóna una dada estadística amb intervals de confiança.

Instal·la benchstat si no el tens:

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

El flux de treball és guardar la sortida dels teus benchmarks en fitxers i comparar-los:

go test -bench=. -benchmem -count=10 ./... > old.txt

# Fas els teus canvis en el codi

go test -bench=. -benchmem -count=10 ./... > new.txt

benchstat old.txt new.txt

La sortida de benchstat et dóna alguna cosa com:

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% és la variació entre execucions. El p=0.000 indica que la diferència és estadísticament significativa (p < 0.05). Si benchstat et diu ~ (p=0.342), la diferència no és significativa i no hauries de tractar-la com una millora real.

Això és fonamental. Un benchmark individual pot donar resultats diferents segons la càrrega del sistema, la temperatura de la CPU, o el que estigui fent el teu navegador en segon pla. benchstat amb -count=10 et protegeix contra això.


Errors de mesura habituals

El compilador t’optimitza el codi

Go té un compilador agressiu. Si el resultat de la teva funció no s’usa en cap lloc, el compilador pot eliminar la crida sencera. El teu benchmark mesuraria literalment res.

Malament:

func BenchmarkMalament(b *testing.B) {
    for i := 0; i < b.N; i++ {
        ConcatBuilder(parts) // el resultat es descarta
    }
}

En molts casos això funciona perquè el compilador no és tan agressiu amb funcions complexes. Però en funcions simples o pures, pot eliminar-ho. La solució estàndard és assignar el resultat a una variable de paquet:

var result string

func BenchmarkBe(b *testing.B) {
    var r string
    for i := 0; i < b.N; i++ {
        r = ConcatBuilder(parts)
    }
    result = r
}

La variable result és de paquet, així que el compilador no pot assumir que ningú l’usa. Assignem a r dins del bucle per evitar una escriptura a variable global en cada iteració (que seria un side effect mesurable) i copiem al final.

b.ResetTimer: setup que no vols mesurar

Si el teu benchmark necessita setup costós abans de mesurar, usa b.ResetTimer() perquè aquell setup no compti:

func BenchmarkAmbSetup(b *testing.B) {
    // Setup costós: crear dades, obrir connexions, etc.
    data := generarDadesDeProva(10000)

    b.ResetTimer() // El cronòmetre es reinicia aquí

    for i := 0; i < b.N; i++ {
        processarDades(data)
    }
}

Sense b.ResetTimer(), el temps de generarDadesDeProva contaminaria la teva mesura. Especialment problemàtic si aquell setup tarda més que la funció que vols mesurar.

b.StopTimer i b.StartTimer: pausar entre iteracions

De vegades necessites fer treball entre iteracions que no vols mesurar. Per exemple, reiniciar un estat:

func BenchmarkAmbPausa(b *testing.B) {
    for i := 0; i < b.N; i++ {
        b.StopTimer()
        data := generarDadesFresques() // no es mesura
        b.StartTimer()

        processarDades(data) // això sí es mesura
    }
}

Usa això amb cura. Cridar a b.StopTimer() i b.StartTimer() dins del bucle té overhead propi. Si la teva funció és molt ràpida (nanosegons), aquell overhead pot ser major que el que mesurem. En aquells casos, és millor preparar totes les dades fora del bucle.

Benchmarks que depenen de l’estat previ

Cada iteració hauria de ser independent. Si la teva funció modifica un estat compartit entre iteracions, les mesures es contaminen:

// MALAMENT: el map creix en cada iteració
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ó més lenta
    }
}

Aquí, les últimes iteracions són més lentes que les primeres perquè el map és més gran. El benchmark et dóna una mitjana que no representa cap cas real.


Benchmarks de memòria: b.ReportAllocs

Ja has vist que -benchmem et dóna estadístiques de memòria. També pots activar el reporte d’allocations des del propi benchmark amb b.ReportAllocs():

func BenchmarkAmbReportAllocs(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        ConcatPlus(parts)
    }
}

Això és útil quan vols que les allocations sempre es reportin per a un benchmark concret, sense dependre que qui l’executi recordi posar -benchmem.

Per què les allocations importen

En Go, cada allocation de heap és treball futur per al garbage collector. Menys allocations significa menys pressió sobre el GC, la qual cosa es tradueix en menor latència i menys pauses. En aplicacions d’alta càrrega, com les que construiries si estàs usant Go per a tasques pesades, la diferència entre 0 allocations i 3 allocations per operació pot ser la diferència entre p99 de 5ms i p99 de 50ms.

Una bona pràctica és començar mesurant allocations abans de mirar els nanosegons. Si una funció fa 10 allocations per crida i la crides 100.000 vegades per segon, això és un milió d’allocations per segon que el GC ha de gestionar.

Pre-alocar per reduir allocations

Una optimització comuna que els benchmarks revelen és la pre-alocació de slices i maps:

func BenchmarkSliceSensePrealocar(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := []int{}
        for j := 0; j < 1000; j++ {
            s = append(s, j)
        }
    }
}

func BenchmarkSlicePrealocat(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)
        }
    }
}

Executa això amb -benchmem i veuràs que la versió sense pre-alocar fa entre 10 i 20 allocations (cada vegada que el slice creix, Go aloca un backing array més gran). La versió pre-alocada fa una sola allocation.


Profiling amb pprof: el bàsic

Els benchmarks et diuen quant tarda alguna cosa. pprof et diu on es gasta el temps. Són eines complementàries.

Per a generar un perfil de CPU des dels teus benchmarks:

go test -bench=BenchmarkConcatPlus -cpuprofile=cpu.out ./...

Això genera un fitxer cpu.out que pots analitzar amb go tool pprof:

go tool pprof cpu.out

Dins de pprof, les comandes més útils:

(pprof) top10          # les 10 funcions que més CPU consumeixen
(pprof) list ConcatPlus  # mostra el codi font anotat amb temps
(pprof) web            # obre un graf interactiu al navegador

Per a perfils de memòria:

go test -bench=BenchmarkConcatPlus -memprofile=mem.out ./...
go tool pprof mem.out

Dins del perfil de memòria, top10 et mostra quines funcions alocan més memòria, i list et assenyala les línies exactes.

El flux complet

El flux pragmàtic per a optimitzar rendiment en Go és:

  1. Benchmark: identifica quant tarda cada operació.
  2. pprof CPU: identifica on es gasta el temps.
  3. pprof memòria: identifica què està alocant.
  4. Optimitza: canvia només el que les dades et diuen que importa.
  5. Benchmark de nou: verifica que el teu canvi realment ha millorat alguna cosa.
  6. benchstat: confirma que la millora és estadísticament significativa.

Si et saltes el pas 1 i vas directe a optimitzar, estàs on jo estava fa uns anys: perdent dos dies en alguna cosa que no importa.


Quan fer benchmarks i quan no

  • Quan estàs escollint entre dues implementacions i el rendiment és un factor.
  • Abans d’optimitzar qualsevol cosa. Mesura primer.
  • Quan un canvi toca codi en el hot path de la teva aplicació.
  • Per validar que una optimització realment ha millorat alguna cosa.
  • En el CI, per detectar regressions de rendiment entre commits.

No

  • Per a codi que s’executa una vegada en arrencar l’aplicació.
  • Per a endpoints que fan I/O (HTTP, base de dades): el benchmark no captura la latència de xarxa.
  • Com a substitut d’un load test. Els benchmarks mesuren funcions aïllades. Un load test mesura el sistema complet.
  • Quan la diferència entre les dues opcions és de nanosegons i la teva aplicació gestiona 10 requests per segon. La llegibilitat importa més.

La regla general: si no pots articular per què el rendiment d’aquella funció específica és crític, probablement no necessites un benchmark. I si ho necessites, les dades t’ho diran ràpid.


Exemple pràctic: comparar dues implementacions

Anem a un cas més realista. Suposem que tens un servei que filtra usuaris per un conjunt de rols. Dos enfocaments:

// users.go
package users

type User struct {
    Name  string
    Roles []string
}

// FiltrarAmbSlice recorre els rols de cada usuari amb un slice.
func FiltrarAmbSlice(users []User, rolsPermesos []string) []User {
    var resultat []User
    for _, u := range users {
        for _, rol := range u.Roles {
            if conteSlice(rolsPermesos, rol) {
                resultat = append(resultat, u)
                break
            }
        }
    }
    return resultat
}

func conteSlice(s []string, val string) bool {
    for _, v := range s {
        if v == val {
            return true
        }
    }
    return false
}

// FiltrarAmbMap converteix els rols permesos a un map per a cerca O(1).
func FiltrarAmbMap(users []User, rolsPermesos []string) []User {
    permesos := make(map[string]struct{}, len(rolsPermesos))
    for _, r := range rolsPermesos {
        permesos[r] = struct{}{}
    }

    var resultat []User
    for _, u := range users {
        for _, rol := range u.Roles {
            if _, ok := permesos[rol]; ok {
                resultat = append(resultat, u)
                break
            }
        }
    }
    return resultat
}

La primera implementació és O(nmk) on n=usuaris, m=rols per usuari, k=rols permesos. La segona és O(n*m) perquè la cerca en el map és O(1). En teoria, el map hauria de guanyar. Però el map té overhead de creació i hashing. Amb pocs rols permesos, el slice podria ser més ràpid.

El benchmark:

// users_test.go
package users

import \"testing\"

func generarUsuaris(n int) []User {
    users := make([]User, n)
    rols := []string{\"admin\", \"editor\", \"viewer\", \"moderator\", \"guest\"}
    for i := range users {
        users[i] = User{
            Name:  \"user\",
            Roles: []string{rols[i%len(rols)], rols[(i+1)%len(rols)]},
        }
    }
    return users
}

var rolsPermesos = []string{\"admin\", \"editor\", \"moderator\"}
var resultatSink []User

func BenchmarkFiltrarAmbSlice_100(b *testing.B) {
    users := generarUsuaris(100)
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        resultatSink = FiltrarAmbSlice(users, rolsPermesos)
    }
}

func BenchmarkFiltrarAmbMap_100(b *testing.B) {
    users := generarUsuaris(100)
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        resultatSink = FiltrarAmbMap(users, rolsPermesos)
    }
}

func BenchmarkFiltrarAmbSlice_10000(b *testing.B) {
    users := generarUsuaris(10000)
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        resultatSink = FiltrarAmbSlice(users, rolsPermesos)
    }
}

func BenchmarkFiltrarAmbMap_10000(b *testing.B) {
    users := generarUsuaris(10000)
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        resultatSink = FiltrarAmbMap(users, rolsPermesos)
    }
}

Fixa’t en diversos detalls:

  1. Generem les dades fora del bucle i usem b.ResetTimer() per no mesurar la generació.
  2. Assignem a resultatSink (variable de paquet) per evitar que el compilador elimini la crida.
  3. Provem amb dues mides (100 i 10.000) perquè el comportament pot canviar amb el volum de dades.

Amb 3 rols permesos, és possible que la versió amb slice guanyi en el cas de 100 usuaris (l’overhead del map no compensa). I que la versió amb map guanyi amb 10.000 usuaris. O potser no. Aquell és exactament el punt: el benchmark et dóna la resposta en lloc d’obligar-te a endevinar.

Si els resultats mostren que la diferència és marginal, tria la implementació més llegible. Si una és un 10x més ràpida amb el teu volum de dades real, tria la més ràpida. Però deixa que els números decideixin.

Sub-benchmarks per parametritzar

Go permet sub-benchmarks amb b.Run, que són ideals per provar múltiples mides sense duplicar funcions:

func BenchmarkFiltrarAmbSlice(b *testing.B) {
    for _, size := range []int{10, 100, 1000, 10000} {
        users := generarUsuaris(size)
        b.Run(fmt.Sprintf(\"size=%d\", size), func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                resultatSink = FiltrarAmbSlice(users, rolsPermesos)
            }
        })
    }
}

La sortida serà:

BenchmarkFiltrarAmbSlice/size=10-8      ...
BenchmarkFiltrarAmbSlice/size=100-8     ...
BenchmarkFiltrarAmbSlice/size=1000-8    ...
BenchmarkFiltrarAmbSlice/size=10000-8   ...

Pots filtrar sub-benchmarks específics:

go test -bench=FiltrarAmbSlice/size=1000 ./...

Això fa els benchmarks molt més mantenibles. En lloc d’escriure una funció per cas, parametritzes i deixes que el framework faci la feina.


Benchmarks paral·lels: b.RunParallel

Si el teu codi s’executa en un context concurrent (i en Go, gairebé tot ho fa), pots mesurar rendiment sota concurrència amb b.RunParallel:

func BenchmarkFiltrarConcurrent(b *testing.B) {
    users := generarUsuaris(1000)
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            FiltrarAmbMap(users, rolsPermesos)
        }
    })
}

b.RunParallel llança múltiples goroutines que executen el teu codi en paral·lel. Cada goroutine itera cridant a pb.Next() en lloc d’usar b.N directament. El framework s’encarrega de distribuir les iteracions.

Això és útil per detectar contencions: locks, atomic operations, false sharing en caché. Una funció que és ràpida en un benchmark seqüencial pot ser lenta quan deu goroutines l’executen simultàniament.


Mesurar primer, optimitzar després

Tot el que hem vist es redueix a una idea: no optimitzis basant-te en el que creus que és lent. Mesura, confirma i només llavors actua.

Go et dóna les eines integrades a la llibreria estàndard. No necessites frameworks externs, ni configuració especial, ni llicències. testing.B és allà des del primer dia, llest per usar en qualsevol fitxer _test.go.

El flux resumit:

  1. Escriu un benchmark per a la funció que sospites que és lenta.
  2. Executa amb -benchmem -count=5 per tenir dades de CPU i memòria.
  3. Usa benchstat per comparar abans i després amb significació estadística.
  4. Genera perfils amb -cpuprofile i -memprofile si necessites saber exactament on es gasta el temps.
  5. Optimitza només el que les dades assenyalin com a coll d’ampolla real.
  6. Mesura de nou per confirmar que el teu canvi ha millorat alguna cosa.

Si el benchmark et diu que la diferència entre dues implementacions és de 20 nanosegons i el teu endpoint tarda 200 mil·lisegons, tanca el benchmark i dedica el teu temps a alguna cosa que importi. Si et diu que una implementació aloca 50 vegades menys memòria en un hot path que processa 100.000 requests per segon, has trobat alguna cosa que val la pena optimitzar.

Les dades no tenen opinions. La teva intuïció sí. Confia en les dades.

Articles relacionats

OshyTech

Enginyeria backend i de dades orientada a sistemes escalables, automatització i IA.

Navegació

Copyright 2026 OshyTech. Tots els drets reservats