- Al hacer builds repetidos con Docker de un sitio web hecho en Rust, surgieron problemas con el tiempo de compilación
- Con la configuración predeterminada de Docker, se producía una recompilación completa de todas las dependencias en cada ocasión, lo que tomaba más de 4 minutos
- Incluso usando
cargo-chef y herramientas de caché, la compilación del binario final seguía tomando mucho tiempo
- El perfilado mostró que la mayor parte del tiempo se consumía en LTO (optimización en tiempo de enlace) y la optimización de módulos de LLVM
- Ajustando las opciones de optimización, la información de depuración y la configuración de LTO se puede mejorar algo, pero se confirmó que la compilación final del binario seguía tomando al menos 50 segundos
Planteamiento del problema y contexto
- Cada vez que se modificaba el sitio web personal hecho en Rust, se repetía la tarea molesta de compilar un binario con enlace estático, copiarlo al servidor y reiniciarlo
- Se intentó migrar a un despliegue basado en contenedores como Docker o Kubernetes, pero la velocidad de build de Rust en Docker resultó ser un gran problema
- Dentro de Docker, incluso con cambios pequeños en el código, había que recompilar todo desde cero, lo que generaba una situación ineficiente
Build de Rust en Docker: enfoque básico
- El enfoque habitual en un Dockerfile consiste en copiar todas las dependencias y el código fuente, y luego ejecutar cargo build
- En este caso no se aprovechan las ventajas del caché, por lo que se repite una recompilación completa
- En el caso de este sitio web, el build completo tomaba alrededor de 4 minutos, con tiempo adicional para descargar dependencias
Mejora del caché en builds de Docker: cargo-chef
- Con la herramienta
cargo-chef, se puede guardar en caché por adelantado una capa separada solo con las dependencias
- Así, cuando cambia el código, se puede reutilizar la compilación de dependencias y mejorar la velocidad del build
- En la práctica, solo el 25% del tiempo total se concentraba en compilar dependencias, y la compilación del binario final del servicio web seguía tomando bastante tiempo (entre 2 minutos 50 segundos y 3 minutos)
- A pesar de estar compuesto por dependencias importantes (axum, reqwest, tokio-postgres, etc.) y unas 7,000 líneas de código propio, una sola ejecución de rustc tardaba 3 minutos
Análisis del tiempo de build de rustc: cargo --timings
- Con
cargo --timings se puede revisar el tiempo de compilación por crate (unidad de compilación)
- El resultado confirmó que la compilación del binario final representa la mayor parte del tiempo total
- Aunque ayuda a analizar la causa con más detalle, no permite entender con precisión el comportamiento interno específico del compilador
Uso del perfilado interno de rustc (-Zself-profile)
- Se activó la función de perfilado interno de rustc con la bandera -Zself-profile para medir con detalle los tiempos de ejecución
- Para ello, el perfilado se habilitó mediante variables de entorno
- Al analizar con la herramienta de resumen, se descubrió que LLVM LTO (optimización en tiempo de enlace) y la generación de código de módulos de LLVM consumían más del 60% del tiempo total
- La visualización con flamegraph también mostró que la etapa codegen_module_perform_lto consumía el 80% del tiempo total
LTO (optimización en tiempo de enlace) y opciones de optimización del build
- En Rust, el build se divide por defecto en codegen units, y luego LTO aplica la optimización global en una etapa relativamente tardía
- LTO tiene varias opciones como off, thin y fat, cada una con impacto en el rendimiento y en el artefacto final
- En el proyecto del autor,
Cargo.toml tenía configurado LTO como thin y los símbolos de depuración como full
- Al probar varias combinaciones de LTO y símbolos de depuración, se observó lo siguiente:
- Se confirmó que los símbolos de depuración full aumentaban el tiempo de build y que fat LTO provocaba una demora de compilación de alrededor de 4 veces
- Incluso quitando LTO y los símbolos de depuración, seguían siendo necesarios al menos 50 segundos de compilación
Optimización adicional y reflexiones
- Un tiempo de 50 segundos no representa un gran problema para un sitio con carga real casi nula, pero por curiosidad técnica se intentó profundizar más en el análisis
- Si se aprovecha bien la compilación incremental (incremental compilation) con Docker, se podrían lograr builds más rápidos, aunque eso requiere combinar limpieza del entorno de build con el caché de Docker
Perfilado detallado de la etapa de LLVM
- Incluso después de quitar LTO y los símbolos de depuración, la etapa LLVM_module_optimize seguía consumiendo cerca del 70% del tiempo
- Se identificó que el costo de optimización era alto debido al valor predeterminado opt-level (3) en el perfil release, y se probó reducir el opt-level solo para el binario
- Los experimentos con varias combinaciones de optimización mostraron que sin optimización (opt-level=0) el tiempo era de unos 15 segundos, mientras que con optimización aplicada (1~3) era de unos 50 segundos
Análisis profundo de eventos de rastreo de LLVM
- Con banderas adicionales de rustc (
-Z time-llvm-passes, -Z llvm-time-trace) se puede rastrear en detalle el tiempo de ejecución de cada etapa de LLVM
-Z time-llvm-passes genera una salida tan grande que muchas veces supera el límite de logs de Docker, por lo que es necesario ajustar la configuración de logs
- Si se guardan los logs en un archivo para analizarlos, se puede revisar por separado el tiempo de ejecución de cada pasada de optimización de LLVM
- La opción
-Z llvm-time-trace genera una enorme salida JSON en formato de chrome tracing, y el archivo se vuelve tan grande que es difícil usar herramientas comunes de edición o análisis de texto
- Si se divide y procesa por líneas (jsonl), se puede analizar en entornos CLI o con scripts
Hallazgos principales y conclusión
- Al compilar proyectos complejos de Rust con Docker, el cuello de botella en la velocidad de build ocurre principalmente en la compilación del binario final y en las etapas de optimización relacionadas de LLVM
- Al ajustar LTO, los símbolos de depuración y opt-level, existe un trade-off claro entre el tiempo de build y el tamaño del binario
- Si se ajustan agresivamente las opciones de optimización, es posible reducir mucho el tiempo de build, aunque sin optimización puede haber una caída de rendimiento
- Si se trabaja con dependencias grandes de crates y en entornos donde la eficiencia del build es importante, una buena estrategia es usar activamente el perfilado para identificar con precisión los cuellos de botella
- Al diseñar un pipeline de build en Rust, hace falta una combinación cuidadosamente afinada de LTO, opt-level, símbolos de depuración y estrategia de caché
1 comentarios
Opiniones en Hacker News
El proyecto de Rust a menudo se ve pequeño a simple vista, y eso resulta interesante. Primero, las dependencias no están relacionadas con el tamaño real de la base de código. En C++, las dependencias de proyectos grandes a menudo se incluyen por
vendoringo simplemente no se usan, así que si algo va lento en 400 mil líneas de código uno puede pensar: “bueno, tiene sentido, hay mucho código”. Segundo, lo mucho más problemático son los macros. Los macros que se expanden repitiendo 10 o 100 líneas pueden convertir muy rápido un proyecto de 10 mil líneas en uno de un millón. Tercero, están los genéricos. Cada instanciación de un genérico consume recursos de CPU. Aun así, por defenderlo un poco, gracias a estas funciones existe la ventaja de que algo que en C serían 100 mil líneas o en C++ 25 mil, en Rust puede reducirse a apenas unos miles. Pero también es cierto que el ecosistema puede parecer lento por el uso excesivo de estas funciones. Por ejemplo, en nuestra empresa usamos async-graphql; la librería en sí es excelente, pero depende muchísimo de los procedural macros. Hay issues de rendimiento abiertos desde hace años, y cada vez que agregamos un tipo de dato se siente claramente que el compilador se vuelve más lentoRyan Fleury hizo Epic RAD Debugger en C, con 278 mil líneas de código y un build de tipo unity (todo el código como un solo archivo, una sola unidad de compilación), y en Windows una compilación limpia tarda apenas 1.5 segundos. Solo este caso ya demuestra que compilar puede ser increíblemente rápido, y me pregunto por qué no se puede lograr algo parecido en Rust o Swift
foo(int)cuando la función esfoo(char*)Qué bueno que Go haya priorizado la velocidad de compilación por encima de la optimización. Para servidores, networking y código glue, que compile realmente rápido es más importante que cualquier otra cosa. También quiero cierta estabilidad de tipos, pero sin que interfiera con la capacidad de prototipar de forma flexible. Tener GC también es práctico. Creo que, tras la experiencia de escalar desarrollo dentro de Google, llegaron a la conclusión de que tipos simples, GC y compilación ultrarrápida eran mucho más importantes que la velocidad de ejecución o la perfección semántica. Basta ver los grandes ejemplos de software de networking e infraestructura hecho en Go para notar que la elección fue totalmente acertada. Claro, en entornos donde el GC sea inaceptable o donde la corrección perfecta sea más importante, quizá no usarías Go, pero en mi entorno de trabajo las decisiones de Go son óptimas
No logro entender la afirmación de que instalar un solo binario estático sea más simple que gestionar contenedores
En mi laptop (Mac M4 Pro), compilar completo Deno (un gran proyecto en Rust) toma 2 minutos. Viéndolo por comando,
debugtarda unos 1 minuto 54 segundos yreleaseunos 8 minutos 17 segundos. Son cifras medidas sin incremental compilation. En realidad, los builds de despliegue corren en un sistema CI/CD, así que no tienes que esperar personalmente¿En qué parte entra Cranelift? En mi caso, desarrollando juegos en Rust, los tiempos de compilación eran tan largos que casi lo abandono. Investigando, vi que LLVM es lento sin importar el nivel de optimización. Es algo que los desarrolladores de Jai siempre han señalado. Incluso tuve la experiencia de bajar el build time de 16 segundos a 4 usando Cranelift. ¡Mis respetos para el equipo de Cranelift!
subsecond, y como dice el nombre permite hot reload del sistema en menos de 1 segundo, lo cual ayudó mucho para prototipado de UI. https://github.com/TheBevyFlock/bevy_simple_subsecond_systemNo creo que Rust sea lento. Comparado con lenguajes del mismo nivel, es lo suficientemente rápido, y frente a compilaciones de C++/Scala que tardaban 15 minutos, es mucho mejor
Como exdesarrollador de C++, no entiendo muy bien la idea de que los builds de Rust sean lentos
La compilación incremental es realmente poderosa. Después del build inicial, puedes congelar un snapshot del caché incremental y, si no hay cambios, reutilizarlo tal cual para build/deploy rápidos. También se lleva bien con Docker. Salvo que cambie la versión del compilador o haya una actualización grande del sitio web, no se tocan las capas del build de imagen. Si solo cambia el código, puedes configurarlo para que esa capa no se vuelva a reconstruir y así ganar eficiencia
El build time de mi página personal es 73 ms. El static site generator recompila en apenas 17 ms. La ejecución real del generator toma solo 56 ms. Adjunto el log de build de zig
cargo watch. Si además usas incremental linking y hotpatch comosubsecond[0], puede ser todavía más rápido. No llega al nivel de Zig, pero queda bastante cerca. Si esos 331 ms que se mencionaron arriba eran de un buildclean(sin caché), entonces sí es mucho más rápido que el build clean de 12 segundos de mi sitio. [0]: https://news.ycombinator.com/item?id=44369642