- La paralelización del compilador de Rust hasta ahora dependía principalmente de Cargo y del backend de LLVM, pero ahora también suma la ejecución paralela del frontend, lo que puede reducir los cuellos de botella en la parte final del build
- En nightly se puede activar la función experimental con
-Z threads=8; el valor predeterminado sigue siendo el modo de un solo hilo, así que no hay mejora de velocidad sin una configuración explícita
- El nuevo frontend se basa en Rayon para dividir tareas de compilación de grano fino, y en una medición de ejemplo el tiempo del frontend bajó de 10.2 s a 5.9 s
- En mediciones con código real, el tiempo de compilación se redujo hasta 50%, pero varía mucho según las características del código y la configuración del build, y el uso de memoria puede aumentar hasta 35%
- La función todavía está en etapa experimental; el objetivo es estabilizar
-Z threads y habilitar por defecto la ejecución multihilo en stable en 2024
Un nuevo eje para paralelizar la compilación de Rust
- El frontend del compilador de Rust ahora puede reducir el tiempo de compilación mediante ejecución paralela
- En el compilador nightly, se puede probar el frontend multihilo pasando la opción
-Z threads=8
- Esta función todavía es experimental, y el objetivo para incluirla en el compilador stable es 2024
Optimizaciones existentes y cuellos de botella restantes
- El Compiler Performance Working Group lleva varios años mejorando el rendimiento del compilador de Rust
- Durante los primeros 10 meses de 2023, el tiempo promedio de compilación medido por las herramientas de performance se redujo un 13%
- El uso máximo de memoria bajó un 15%
- El tamaño de los binarios se redujo un 7%
- Como el compilador ya está bastante optimizado, el margen restante para grandes mejoras está más cerca de ampliar la paralelización
Límites de la paralelización de Cargo y del backend
- Al compilar un programa en Rust, Cargo ejecuta varios procesos
rustc para compilar crates en paralelo
- Si se desactiva esta paralelización con la bandera
-j1, el tiempo de compilación de programas grandes en Rust aumenta de forma importante
- La bandera
--timings de Cargo genera un gráfico de línea de tiempo de compilación de crates
- En un ejemplo donde se compila ripgrep en una máquina con 28 cores virtuales, aparecen 60 líneas de procesos
- La mayoría son
rustc y algunos son build scripts
- Los primeros 20 procesos no tienen dependencias entre crates, por lo que pueden iniciarse al mismo tiempo
- Hacia la parte final del build aumentan las dependencias entre crates y disminuye la paralelización
- Con pipelined compilation se puede solapar en cierta medida la compilación de crates dependientes, pero en programas grandes de Rust la ejecución paralela hacia el final del build se reduce mucho
Qué hacen el frontend y el backend
- El compilador de Rust se divide, a grandes rasgos, en frontend y backend
- El frontend realiza parsing, verificación de tipos, borrow checking, etc.
- El frontend existente no podía usar ejecución paralela
- El backend se encarga de la generación de código: genera código en unidades llamadas “codegen units” y luego LLVM las procesa en paralelo
- En builds de release, la cantidad predeterminada de codegen units es 16, por lo que en el perfil de ejemplo aparecen 16 hilos de LLVM
Cuellos de botella revelados por el perfil del backend existente
- En una medición de ejemplo con Samply al compilar en modo release el último crate de Cargo, el frontend tardó 10.2 segundos
- El backend tardó 6.2 segundos, y los hilos de LLVM se ejecutaron durante 5.9 segundos de ese tiempo
- La generación paralela de código basada en LLVM es efectiva, pero incluso en una máquina de 28 cores no se ejecutaron al mismo tiempo los 16 hilos de LLVM
- El main thread convierte MIR a LLVM IR de forma serial, lo que produce una forma escalonada al inicio de los codegen threads
- El frontend, que funcionaba de manera completamente serial, seguía siendo el principal punto de mejora
Cómo está implementado el nuevo frontend paralelo
- El nuevo frontend usa Rayon para realizar tareas de compilación paralelas de grano fino
- Varias estructuras de datos se sincronizan con mutexes y read-write locks, y donde hace falta se usan tipos atómicos
- Muchas tareas del frontend se paralelizaron, pero los cambios se concentraron en un número relativamente pequeño de puntos clave
- La mayor parte del código del frontend no tuvo que cambiarse
Resultados de medición con una configuración de 8 hilos
- En el mismo ejemplo, al activar el frontend paralelo y usar 8 hilos, el tiempo de ejecución del frontend bajó de 10.2 segundos a 5.9 segundos
- El tiempo de ejecución del backend bajó de 6.2 segundos a 5.3 segundos, y el tiempo de ejecución de los hilos de LLVM bajó de 5.9 segundos a 4.9 segundos
- En el frontend operan 7 hilos adicionales marcados como
rustc
- El uso de hilos no es uniforme y los 8 hilos tienen tramos inactivos, por lo que aún queda margen de mejora
- La razón por la que 8 hilos de LLVM arrancan al mismo tiempo es que los 8 hilos de
rustc generan en paralelo el LLVM IR de 8 codegen units
- Si se cambia la cantidad de hilos del frontend a 16, la forma escalonada desaparece por completo, pero en ese caso el tiempo final de ejecución casi no cambia
Combinación de paralelización entre procesos y dentro de procesos
- La compilación de Rust se ha beneficiado desde hace mucho de la paralelización entre procesos de Cargo y de la paralelización dentro de procesos del backend
- Ahora el frontend también puede aprovechar la paralelización dentro de procesos
- Cuando se ejecutan varios procesos
rustc al mismo tiempo y cada proceso crea varios hilos, el jobserver protocol limita la cantidad de hilos
- Si la paralelización entre procesos es alta, la paralelización dentro de procesos se reduce en consecuencia, y el número total de hilos no supera la cantidad de cores
Cómo usarlo
- El compilador nightly incluye el frontend paralelo
- El valor predeterminado es el modo de un solo hilo, así que tal cual no reduce el tiempo de compilación
- El modo multihilo debe activarse explícitamente con la opción
-Z threads
RUSTFLAGS="-Z threads=8" cargo build --release
- Para configurarlo con
config.toml en uno o más proyectos, agrega lo siguiente
[build]
rustflags = ["-Z", "threads=8"]
- La razón por la que el modo de un solo hilo es el predeterminado es una implementación gradual y prudente
- El frontend paralelo contiene mucho código nuevo
- El modo de un solo hilo ejecuta la mayor parte del código nuevo, pero excluye la posibilidad de bugs de threading como deadlocks
- Incluso en Rust, los programas paralelos son más difíciles de escribir correctamente que los programas seriales
- Por eso el frontend paralelo no se incluirá por ahora en releases beta o stable
Impacto en rendimiento y memoria
- En modo de un solo hilo, el frontend paralelo suele ser entre 0% y 2% más lento que el frontend serial existente
- En modo multihilo con
-Z threads=8, las mediciones con código real muestran que el tiempo de compilación puede reducirse hasta 50%
- El impacto en el rendimiento varía mucho según las características del código y la configuración del build
- Es probable que los builds de desarrollo vean mejoras mayores que los builds de release
- Esto se debe a que los builds de release normalmente pasan más tiempo en optimizaciones del backend
- En algunos programas pequeños que ya compilan rápido, el modo multihilo puede ser más lento que el modo de un solo hilo
- El valor recomendado es 8 hilos
- Es la configuración más probada y se sabe que da buenos resultados
- Valores menores que 8 ofrecen menos beneficios, aunque son adecuados para hardware con menos de 8 cores
- Valores mayores que 8 tienen rendimientos decrecientes y también pueden empeorar el rendimiento
- La razón por la que pasar de 1 a 8 hilos solo logra una mejora de alrededor de 50% es que el frontend representa solo una parte del tiempo total de compilación y el backend ya está paralelizado
- En modo multihilo, el uso de memoria puede aumentar de forma considerable; se observó un aumento de hasta 35%
Corrección y feedback
- Se espera que la confiabilidad del modo de un solo hilo sea alta
- El modo multihilo tiene bugs conocidos, incluidos deadlocks
- Si la compilación se queda detenida, es posible que hayas encontrado uno de los bugs conocidos
- Sea cual sea el frontend usado, los binarios generados por el compilador deberían ser iguales; si hay diferencias, se considera un bug
- Si hay problemas, primero conviene revisar los issues con la etiqueta
WG-compiler-parallel, y si no existe un issue que coincida, se puede abrir uno nuevo
- El feedback general se recibe en el canal de Zulip wg-parallel-rustc, y hay especial interés en el impacto de rendimiento con código real
Objetivo stable para 2024
- El trabajo para mejorar el rendimiento del frontend paralelo sigue en curso
- Como se vio en el perfil, el uso de hilos del frontend todavía tiene margen de mejora
- También se están resolviendo los bugs restantes del modo multihilo
- El objetivo para estabilizar la opción
-Z threads y ofrecer el frontend paralelo con multihilo por defecto en un release stable es 2024
1 comentarios
Opiniones en Hacker News
Sé que todavía está en una etapa temprana, pero creo que la desventaja de Rust es la velocidad de compilación.
Cuando trabajé en un monorepo de Rust, mi mayor queja era la velocidad de compilación; aumentaba los costos de CI/CD y, cuando había que limpiar la caché, también ralentizaba mucho el tiempo de desarrollo.
La causa era un bug de Docker, no Cargo, pero aun así este avance se agradece.
Ya está muy optimizado, y el compilador actual de Rust tiene más paralelismo que casi todos los compiladores mainstream.
El propio diseño del lenguaje Rust hace que compilarlo sea más difícil que en lenguajes como Go, que fueron creados con la compilación rápida como objetivo.
No sé qué tan directamente se aplicará este trabajo, pero ojalá también haya grandes mejoras ahí.
Sin duda es algo necesario para el soporte moderno en IDEs.
Soy maintainer de un proyecto open source de Rust de tamaño mediano [1], y localmente el tiempo de compilación de Rust siempre me parece sorprendentemente rápido.
En una MacBook Pro, las builds de debug tardan apenas unos segundos; las builds de release y CI/CD son lentas, pero desde que empecé con Rust hace 2 años, la compilación de Rust me ha parecido muy rápida.
Para equilibrar la comparación, mi trabajo principal es con Java/Kotlin y Gradle, y ahí sí puedo hablar de tiempos de compilación verdaderamente glaciares.
En mi proyecto open source de Rust mantengo las dependencias al mínimo, no uso macros salvo cosas como
derive[Debug, Clone], y uso genéricos con mucha moderación.Me gustaría que compilaran este proyecto con
cargo buildy dieran feedback sobre el tiempo de compilación.[1]: https://github.com/Orange-OpenSource/hurl
Y también si dividieron el proyecto en varios crates en el punto adecuado.
¿Cuántos minutos tardaba la build del monorepo?
Pueden ser lo más sinceros posible. Mi compilador principal es GHC, así que no me sorprendo fácilmente.
Puede ser una pregunta tonta, pero ¿el backend tiene que esperar a que el frontend termine la verificación de préstamos? Si es así, ¿por qué?
No digo que haya algo mal, solo me pregunto si la verificación de préstamos establece invariantes de los que depende el backend, más allá de una simple validación de consistencia.
Por ejemplo, me pregunto si hay alguna razón por la que no pueda hacerse trabajo especulativo en el backend que se pueda descartar si aparece un error de borrow checking.
Aunque quizá no pueda generar el código más optimizado.
Según tengo entendido, hay optimizaciones que usan información establecida durante la verificación de préstamos, como la infame optimización
noalias. Hicieron falta varios intentos para que esta optimización llegara a activarse[1].Tampoco tengo clara la relación con NLL (lifetimes no léxicos), pero parecería que al menos se necesita un verificador de préstamos rudimentario para establecer información que le interese al backend.
Sin embargo, mrustc también compila sin borrow checker versiones de Rust que tienen NLL, así que parece más una cuestión de optimización que un requisito indispensable.
[0]: https://github.com/thepowersgang/mrustc
[1]: https://stackoverflow.com/a/57259339
¿Hay alguna forma de hacer que use el número de cores de CPU, en lugar de meter un valor fijo en archivos de configuración que uso en distintas máquinas?
Esperaría que el valor predeterminado estable sea la cantidad de cores.
No sé hasta dónde llegó este trabajo ahora, pero en algún momento se intentó coordinar las invocaciones de rustc desde Cargo con jobserver; si eso ocurre, terminaría usando el número de trabajos de Cargo, cuyo valor predeterminado es la cantidad de cores.
Cargo también permite usar valores negativos para restar a la cantidad de cores.
RUSTFLAGS, como dice el artículo:¡Bien! Hace mucho tiempo, cuando usaba Rust, incluso los ejemplos de juguete tardaban bastante en compilar, pero volví hace poco y Rust ha mejorado muchísimo; ahora lo uso donde puedo casi sin pensar en los tiempos de compilación.
Dicho eso, en un proyecto que creció un poco, incluso un cambio simple empezó a tardar más de 5 segundos en compilar, y eso me trajo de vuelta recuerdos viejos.
Incluso llegué a querer retrasar el guardado para que el analyzer no arrancara antes de que terminara de ordenar otras cosas, antes de que la laptop sonara como motor de avión.
Para mí es el mayor punto de dolor, así que cualquier avance es muy bienvenido.
¡Bien! A diferencia del ecosistema de crates de librerías, mis crates binarios tendían a ser grandes y monolíticos por defecto.
Ahora los estoy dividiendo en varios crates de librería.
Esto significa que, además de no poder paralelizar la parte final de la compilación, los crates más grandes se procesan secuencialmente, así que este cambio es muy bienvenido.
Después de mantenerme medio alejado de Rust durante varios años y trabajar en entornos como Python o TypeScript, hace poco lo volví a usar en un proyecto y la velocidad de compilación era casi instantánea.
Que mejore más siempre es bueno, pero ya está en un estado bastante excelente.
Hoy, con el atajo de ChatGPT, también se pueden superar casi todos los problemas difíciles de Rust que hace unos años me habrían bloqueado, así que el panorama de Rust se ve bastante bien.
Ahí la build de la imagen Docker tarda entre 60 y 90 minutos, y uno realmente toma conciencia de cuántas dependencias tiene todo el proyecto.
¿Hay alguna forma de desactivar la opción de compilador paralelo sin reconstruir el compilador?
No necesito usarla, de todos modos tengo codegen units configurado en 1, y parece estar provocando un ICE que no quiero depurar.
Sé que el valor predeterminado es 1 thread, pero quiero apagarla por completo.
“El modo multithread tiene bugs conocidos, incluidos deadlocks. Si la compilación se queda colgada, probablemente te topaste con uno de ellos”.
Entonces mejor voy a esperar un poco más antes de usar
-Z threads;)