Cuando compilas un programa en Go, el compilador toma decisiones: qué funciones hacer inline, cómo ordenar el código en memoria, qué ramas del código priorizar. El problema es que esas decisiones las toma en tiempo de compilación, sin saber nada de lo que va a pasar en producción.
Hasta Go 1.20, el compilador usaba heurísticas estáticas. Había funciones que hacía inline aunque en producción apenas se llamaran, y otras que dejaba sin optimizar precisamente porque el análisis estático no podía prever que iban a ser el cuello de botella real. El resultado era un binario correcto pero subóptimo para el workload concreto.
PGO (Profile-Guided Optimization) rompe ese límite. Con PGO, el compilador recibe un perfil de ejecución real, recogido en producción, y usa esos datos para tomar mejores decisiones. El binario resultante es idéntico en comportamiento, pero más rápido porque está optimizado para lo que realmente ocurre, no para lo que el compilador supone que puede ocurrir.
La mejora que informa el equipo de Go está entre el 2 y el 14% dependiendo del workload. Sin cambiar una sola línea de código de la aplicación.
PGO llegó como experimental en Go 1.20 y pasó a ser estable en Go 1.21. La documentación oficial lo explica con detalle.
El flujo de trabajo completo
PGO sigue un ciclo de cuatro pasos que hay que recorrer una vez para arrancar y luego repetir periódicamente para mantener el perfil actualizado.
Paso 1: desplegar la versión normal
Primero compilas y despliegas tu aplicación como siempre, sin ningún perfil. Este paso existe porque necesitas una versión en producción de la que recoger datos reales.
go build -o mi-servicio ./cmd/server/
Paso 2: recoger el perfil CPU con pprof
Go incluye el paquete net/http/pprof para exponer endpoints de perfilado. Añadir el import en tu servidor HTTP es suficiente para activarlos:
import _ "net/http/pprof"
Esto expone el endpoint /debug/pprof/. Desde ahí puedes pedir un perfil de CPU de 30 segundos con curl:
curl "http://localhost:6060/debug/pprof/profile?seconds=30" > cpu.pprof
Esos 30 segundos son los que dan valor al perfil: el compilador va a ver qué funciones consumieron más CPU durante tráfico real, con los usuarios reales de tu aplicación. No con benchmarks de laboratorio ni con tu máquina local.
Paso 3: compilar con el perfil
Con el archivo cpu.pprof en mano, compilas de nuevo pasando el perfil:
go build -pgo=cpu.pprof -o mi-servicio ./cmd/server/
El compilador lee el perfil e incorpora esa información a sus decisiones de optimización. El proceso de compilación tarda un poco más de lo habitual, pero el binario que sale al otro lado ya está ajustado al workload de producción.
Paso 4: desplegar el binario optimizado
Despliegas el nuevo binario como harías con cualquier actualización. A partir de ahí, tu aplicación corre con las optimizaciones activas.
El truco del default.pgo
Go tiene un atajo muy práctico: si guardas el perfil con el nombre default.pgo en el directorio del paquete main, el compilador lo detecta automáticamente sin que tengas que pasar el flag -pgo.
cp cpu.pprof cmd/server/default.pgo
go build -o mi-servicio ./cmd/server/
Esto significa que si subes el default.pgo al repositorio, el build de CI ya usa PGO sin configuración extra. Todos los miembros del equipo compilan con el mismo perfil y el pipeline de producción lo incluye automáticamente. Es la forma más cómoda de integrar PGO en un proyecto existente.
Qué hace exactamente el compilador con el perfil
El perfil le dice al compilador qué funciones son "calientes", es decir, cuáles se llaman con mucha frecuencia y cuáles consumen más CPU. Con esa información, aplica tres tipos de optimizaciones principales.
Inlining más agresivo
Normalmente el compilador hace inline solo en funciones pequeñas, porque el inlining aumenta el tamaño del binario. Con PGO, puede hacer inline funciones más grandes si el perfil muestra que se llaman miles de veces por segundo. El ahorro en overhead de llamada compensa el coste en tamaño.
Devirtualización de interfaces
Las interfaces en Go implican una indirección: cuando llamas a un método de una interfaz, el runtime tiene que resolver qué implementación concreta usar. Si el perfil muestra que en el 99% de los casos esa interfaz siempre apunta al mismo tipo, el compilador puede convertir esa llamada en una llamada directa, eliminando la indirección.
Mejor layout de instrucciones
El código más ejecutado se coloca más cerca en memoria, lo que mejora la utilización de la caché de instrucciones del procesador y ayuda al predictor de ramas. En aplicaciones con bucles muy frecuentes o rutas de código muy transitadas, esto puede marcar una diferencia visible.
Qué mejora esperar según el tipo de aplicación
El equipo de Go informa mejoras del 2 al 7% en servidores HTTP típicos. En aplicaciones con mucho uso de interfaces o muchas funciones pequeñas que se llaman con frecuencia, la ganancia puede llegar hasta el 14%.
Las aplicaciones I/O bound, donde el programa pasa la mayor parte del tiempo esperando respuestas de red o disco, sacan menos partido de PGO. El compilador puede optimizar la CPU todo lo que quiera, pero si el cuello de botella está en la red, la mejora en CPU apenas se nota en los tiempos reales.
PGO también es acumulativo: cada vez que recoges un perfil más representativo del tráfico real y recompilas, el compilador tiene mejores datos para trabajar. El primer perfil ya ayuda, pero uno recogido durante el pico de tráfico de tu aplicación es mejor que uno recogido a las tres de la mañana.
Para entender mejor el rendimiento de Go comparado con Rust y saber en qué contextos cada uno tiene ventaja, merece la pena revisar los benchmarks de ambos lenguajes antes de decidir dónde aplicar esfuerzo de optimización.
Cómo gestionar los perfiles en el tiempo
Un perfil captura un momento concreto del tráfico. Si el comportamiento de tu aplicación cambia, el perfil se queda anticuado y las optimizaciones del compilador dejan de estar tan afinadas.
La recomendación general es actualizar el perfil periódicamente: una vez al mes o después de cambios grandes en la aplicación. No hay que obsesionarse con esto, un perfil de hace tres meses sigue siendo mejor que no tener ninguno, pero renovarlo cuando hay cambios importantes tiene sentido.
Google, que usa Go a escala enorme en sus sistemas internos, actualiza los perfiles de PGO de forma continua como parte de su pipeline de CI. Para proyectos más pequeños, guardar el default.pgo en git y actualizarlo cada cierto tiempo es más que suficiente.
PGO y pprof: el stack de rendimiento de Go
PGO usa los mismos perfiles que ya genera pprof, la herramienta estándar de perfilado de Go. Si ya usas pprof para diagnosticar problemas de rendimiento, el flujo de PGO te resulta inmediatamente familiar.
Puedes recoger perfiles también desde tests de benchmarks:
go test -cpuprofile=cpu.pprof -bench=BenchmarkMiFuncion ./...
Aunque un perfil de benchmarks es menos valioso que uno de producción real, puede ser útil si no tienes acceso directo a la producción o si quieres experimentar con PGO antes de implantarlo.
Para analizar un perfil visualmente antes de usarlo:
go tool pprof -http=:8080 cpu.pprof
Esto abre una interfaz web donde puedes ver el flame graph, el árbol de llamadas y las funciones que más tiempo consumen. Viene bien para entender qué está optimizando el compilador cuando aplica PGO y para identificar cuellos de botella que quizás merezca la pena atacar a nivel de algoritmo antes de delegar todo en el compilador.
Y aquí está el orden correcto de actuación: primero optimiza el algoritmo, luego el código, y PGO al final. El compilador puede sacar más partido de código que ya es eficiente que de código que hace trabajo de más. PGO es la última milla, no el primer paso. Puedes ver más sobre el trabajo con herramientas de rendimiento en esta guía sobre Go en producción: herramientas de rendimiento.
Empezar con PGO en un proyecto existente
Si tienes una aplicación Go en producción y quieres probar PGO, el proceso es sencillo. Añade el import de net/http/pprof si no lo tienes ya, despliega, recoge un perfil de 30 segundos con curl y compila de nuevo con -pgo. Todo el proceso se puede hacer en menos de una hora.
Si la mejora es visible en tus métricas, guarda el perfil como default.pgo en el repositorio y listo: a partir de ahí todos los builds lo usan automáticamente. Si la mejora es marginal porque tu aplicación es I/O bound, al menos ya sabes que el cuello de botella está en otro sitio y puedes enfocar el esfuerzo donde tiene más sentido.
Imagen: Pexels / Sami Aksu
