1 puntos por GN⁺ 2025-06-28 | 1 comentarios | Compartir por WhatsApp
  • 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

 
GN⁺ 2025-06-28
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 vendoring o 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 lento

    • Me pregunto por qué tan seguido se reescriben en Rust cosas que originalmente eran simples, como pequeñas utilidades en C. Lo que veo más a menudo no son ports a Rust de grandes programas en C de 100 mil líneas, sino código de escala muy pequeña. Me da curiosidad cómo se compara Rust con C en velocidad de compilación para programas pequeños. No me interesa el tamaño del programa, sino la velocidad de compilación. Como referencia, según una medición reciente, el tamaño del toolchain del compilador de Rust es aproximadamente el doble del GCC que uso. 1. En programas tan pequeños, sin importar el lenguaje, es menos probable que haya problemas ocultos de seguridad de memoria, y además por su tamaño son más fáciles de auditar. No es la misma situación que un programa en C de 100 mil líneas
    • Cada vez que defines un tipo nuevo se puede sentir en carne propia que el compilador se vuelve más lento. Según recuerdo, el rendimiento del compilador se vuelve exponencialmente más lento dependiendo de la “profundidad” de los tipos. En algo como GraphQL, donde hay muchos tipos anidados, este problema es especialmente grave
    • Para enfrentar el problema de que los macros que se expanden a decenas o cientos de líneas pueden hacer crecer la base de código de forma geométrica, recientemente se añadió soporte en herramientas de análisis. Material relacionado: https://nnethercote.github.io/2025/06/26/how-much-code-does-that-proc-macro-generate.html
  • Ryan 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

    • Mientras más cosas haga el compilador en build time, más largo será el tiempo de build. Go puede lograr tiempos de build por debajo de 1 segundo incluso en bases de código grandes. Tiene un sistema de módulos y de tipos simple, reducido a lo mínimo necesario en build, y además delega la mayoría de las funciones al GC en runtime. En cambio, si se exigen macros, sistemas de tipos complejos y un alto nivel de robustez, es inevitable que el build time aumente
    • En Rust, la unidad de build también es el crate completo, y el compilador lo divide en tamaños adecuados como LLVM IR. Además ajusta por sí solo el balance entre trabajo duplicado e incremental build. Muchas veces Rust compila más rápido que C++ en términos de líneas de código fuente. Pero los proyectos en Rust tienen la característica de compilar también todas las dependencias
    • La razón por la que Rust y Swift compilan más lento que un compilador de C es que el lenguaje mismo requiere muchísimo más análisis. Por ejemplo, el borrow checker de Rust no sale gratis. Solo las verificaciones en tiempo de compilación ya consumen bastantes recursos. C es rápido porque prácticamente no revisa nada más allá de la sintaxis básica. De hecho, C ni siquiera detecta combinaciones raras como llamar foo(int) cuando la función es foo(char*)
    • En los 2000 compilaba proyectos de C++ de decenas de miles de líneas, y el build terminaba en menos de 1 segundo incluso en computadoras viejas. En cambio, un HELLO WORLD usando solo Boost tardaba varios segundos. Al final, la velocidad de build depende mucho no solo del lenguaje o del compilador, sino también de cómo está estructurado el código y qué funciones se usan. Podrías hacer DOOM con macros de C, pero probablemente no sería rápido. Y al revés, también puedes estructurar Rust para que haga builds rápidos
    • No sorprende mucho que lenguajes como C y Go, diseñados para compilar rápido, sean rápidos. Lo realmente difícil es compilar rápido la semántica de Rust. Este problema incluso aparece en el FAQ oficial de Rust
  • 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

    • A mí también me gusta Go, pero no creo que este lenguaje sea producto de una gran inteligencia colectiva organizacional de Google. Si de verdad estuviera impregnado de la experiencia de Google, le habrían agregado cosas como eliminación estática de excepciones por puntero nulo. Más bien parece el resultado de que algunos desarrolladores de Google hicieron el lenguaje que ellos querían
    • Go tiene ventajas como compilación rápida, un sistema de tipos razonable y GC, pero en el espacio de diseño Java ya ocupaba un lugar parecido. Da la impresión de que Go nació sobre todo de un impulso creativo, y al final fue absorbido más por usuarios de lenguajes de scripting (Python/Ruby/JS) que por su objetivo original (C/C++/Java del lado servidor). Los usuarios de scripting solo querían un sistema de tipos fácil y rápido, y Java era demasiado viejo y aburrido. Ya no había espacio para Java en servidores/conferencias/librerías
    • También se dice que un desarrollador de Google diseñó Go mientras esperaba a que compilara un proyecto en C++
    • Quisiera preguntar qué es exactamente un “obnoxious type”. Un tipo simplemente representa correctamente los datos o no lo hace, y en la práctica en cualquier lenguaje puedes forzar al type checker a callarse
    • Go es un lenguaje que encaja muy bien con su propósito de diseño y con su uso real. El mayor riesgo está en el procesamiento paralelo y en compartir estado mutable por canales; ahí pueden aparecer bugs sutiles o frágiles. Normalmente la mayoría de los usuarios no usan ese patrón. Yo uso Rust, pero mi trabajo consiste en exprimir al máximo algoritmos lentos sobre hardware lento. Por eso, la paralelización a gran escala resulta sutilmente imposible
  • No logro entender la afirmación de que instalar un solo binario estático sea más simple que gestionar contenedores

    • Parece que no se entiende claramente qué hace Docker en la práctica. Por ejemplo, se decía “si despliegas con una imagen Docker, cada vez reconstruyes todo desde cero”, pero en un entorno interno de build/deploy ese problema no tiene por qué existir. Para uso personal, incluso podrías simplemente meter en el contenedor el archivo compilado localmente y mantener toda la comodidad del entorno de desarrollo. Solo hay que cuidar las rutas relacionadas con rastros del entorno de build. En CI/CD o en proyectos de equipo, el énfasis está en garantizar que el build pueda generarse desde cero en cualquier lugar, pero en trabajo personal eso no siempre hace falta
    • En el texto original, el objetivo no es simplificar sino modernizar. La idea es: “Como la mayoría del software de los últimos 10 años ha adoptado el despliegue en contenedores como estándar, yo también voy a desplegar mi sitio web en contenedores como Docker o Kubernetes”. Los contenedores tienen varias ventajas: aislamiento de procesos, seguridad, logging estandarizado, escalabilidad horizontal, etc.
  • En mi laptop (Mac M4 Pro), compilar completo Deno (un gran proyecto en Rust) toma 2 minutos. Viéndolo por comando, debug tarda unos 1 minuto 54 segundos y release unos 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

    • Hay un artículo relacionado donde dicen que tardó unos 6 minutos en una M1 Max y unos 11 minutos en una M1 Air
  • ¿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!

    • Hace poco, en un Bevy game jam, usé una herramienta de la comunidad de Dioxus llamada 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_system
    • Tengo entendido que el equipo de zig también está intentando prescindir de LLVM y crear su propio compilador (backend) para hacer los builds muy rápidos
    • Según recuerdo, antes Cranelift no soportaba macOS aarch64, pero hace poco me enteré de que ya sí lo soporta
    • ¿No es un poco exagerado decir que casi abandonaste Rust por un build time de 16 segundos?
  • No 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

    • También coincido. Nunca he sentido que los builds de Rust sean particularmente incómodos. Tal vez esa fama viene de una mala percepción de los primeros tiempos que se fue arrastrando
    • El uso de memoria al compilar es muy grande comparado con C/C++. Para compilar proyectos grandes de Rust en la VM que uso para demos en YouTube necesito más de 8 GB. Con C/C++ no tengo esa preocupación
    • Dado que las plantillas de C++ son Turing completas, comparar solo build time sin tener en cuenta el estilo real del código no tiene mucho sentido
  • Como exdesarrollador de C++, no entiendo muy bien la idea de que los builds de Rust sean lentos

    • Por eso mismo se dice que Rust apunta a desarrolladores de C++. Quien tiene mucha experiencia en C++ ya soporta bastante bien herramientas incómodas, como una especie de síndrome de Estocolmo
    • Aunque sea más rápido que C++, igual puede ser lento en términos absolutos. La mala fama de los builds de C++ ya es bien conocida por todos. Como Rust no carga con esos problemas estructurales del lenguaje, quizá las expectativas son mayores
    • Me parece el caso clásico de seguir agregando funciones nuevas, pero no escuchar mucho a los usuarios reales ni resolver bien sus problemas
    • C era rápido porque sus etapas de compilación eran pocas y simples, pero siento que C++, por el uso de templates, terminó rompiendo la mayor parte del trabajo de encapsulación. Cambias un solo header de template y sientes que termina afectando al 98% de todo el proyecto
  • 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

    • Los artefactos incrementales de mi proyecto ya superan los 150 GB. Cuando usé imágenes Docker de ese tamaño, realmente me encontré con problemas bastante serios
  • 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

    • Siento que en C/C++ siempre aparecen comentarios diciendo que Rust es mejor, y en Rust siempre aparecen comentarios diciendo que Zig es mejor. (Al final resulta que el autor de este comentario era el desarrollador principal de zig). Creo que evangelizar lenguajes le hace daño a la comunidad y, en la práctica, solo genera rechazo en vez de atraer nuevos usuarios. Si de verdad amas un lenguaje, ayudaría más frenar esa cultura de evangelización
    • Más que presentar una sola cifra de tiempo de compilación, habría estado mejor aportar una discusión o interpretación directamente relacionada con el tema del post original
    • Mi sitio web en Rust (incluyendo un framework tipo React y un servidor web real) también tarda alrededor de 1.25 segundos con builds incrementales usando cargo watch. Si además usas incremental linking y hotpatch como subsecond[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 build clean (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
    • Quisiera preguntarle directamente a @AndyKelley cuál cree que es la razón decisiva por la que zig compila tan rápido y Rust/Swift casi siempre son lentos
    • Zig no garantiza seguridad de memoria, ¿cierto?