¿Por qué el compilador de Rust es tan lento?
(sharnoff.io)- En una compilación de un sitio web en Rust para despliegue con Docker, incluso cacheando dependencias, solo el crate final tardaba unos 175 segundos, y el cuello de botella se reducía a
rustcinternamente y a la etapa de optimización de LLVM - Tras aplicar sucesivamente
cargo-chef,cargo --timings,-Zself-profileymeasureme, quedó claro que no era solo un problema simple de dependencias, sino que el costo de LTO y la generación de código de LLVM dominaba el tiempo de compilación - Ajustes antiguos en
Cargo.toml, comolto = "thin"ydebug = "full", tenían un impacto grande, y al desactivarlos la compilación del binario final bajó de 172.2 segundos a unos 50 segundos - En el trazado de LLVM,
OptFunction,InlinerPass,core::ptr::drop_in_place, las funciones async grandes y la monomorfización de genéricos aparecieron como los principales costos, y reducir el inlining, dividir funciones, usarPin<Box<dyn Future>>y eliminar genéricos aportó mejoras adicionales - Finalmente, al aplicar
-Zshare-genericsy cambiar a una compilación basada en Debian, el tiempo de compilación cayó de 29.1 segundos a 9.1 segundos, mostrando que no solo la estructura del código, sino también el allocator y si se usa el objetivo musl, influyen mucho en el tiempo de build
Cuello de botella revelado en la compilación con Docker
- El sitio web se entrega principalmente como un solo binario de Rust, y antes se construía un binario con enlace estático, se copiaba al servidor y luego se reiniciaba el servicio
- Al migrar a un despliegue basado en contenedores, resultó que configurar compilaciones rápidas de Rust en Docker era más difícil de lo esperado
- El Dockerfile básico reconstruía todo cada vez que cambiaba el código fuente
- Usaba
rust:1.87-alpine3.22como builder y compilaba para el targetx86_64-unknown-linux-musl - La imagen final solo copiaba el binario a Alpine
- Una compilación limpia con este método tardaba 3 minutos 51 segundos, incluyendo 10 segundos para descargar crates
- Usaba
Separar la caché de dependencias con cargo-chef ayudó, pero no fue suficiente
- cargo-chef crea un archivo recipe simplificado a partir de un workspace y, con base en él, precompila dependencias en una capa separada de caché de Docker
- Como el sitio web usaba cientos de dependencias, se esperaba un gran efecto de caché
- En las mediciones reales, la compilación de dependencias tardaba 1 minuto 7 segundos, y la compilación del binario final usando dependencias cacheadas tardaba 2 minutos 50 segundos
- Solo alrededor del 25% del tiempo total se iba en dependencias; casi todo el resto se consumía en una sola invocación de
rustcpara el crate finalweb-http-server
cargo --timings y el self-profile de rustc
cargo build --release --timingsmuestra el tiempo de compilación por crate, y el crate final tardó 174.1 segundos, más o menos en línea con los 2 minutos 54 segundos reportados porcargo build- Como el cuello de botella estaba concentrado en un solo crate final,
cargo --timingspor sí solo no bastaba para identificar la causa detallada - Para usar la función de self-profile de
rustc, se utilizó-Zself-profile- Para usar flags inestables
-Zen el compilador estable, se empleóRUSTC_BOOTSTRAP=1 - Para evitar invalidar la caché de
cargo-chef, se usóRUSTFLAGS='-Zself-profile'en vez decargo rustc -- -Z self-profile
- Para usar flags inestables
- Se analizaron los datos del self-profile con las herramientas
summarize,flamegraphycroxde measureme - Los elementos principales en
summarizese concentraban en trabajo relacionado con LLVMLLVM_lto_optimize: 851.95 segundos, 33.389% del totalLLVM_module_codegen_emit_obj: 674.94 segundos, 26.452%LLVM_thin_lto_import: 317.75 segundos, 12.453%LLVM_module_optimize: 189.00 segundos, 7.407%
- En el flamegraph,
codegen_module_perform_ltoocupaba aproximadamente el 80% del tiempo total
Impacto de LTO y la configuración de símbolos de depuración
- El compilador de Rust divide un crate en codegen units y las entrega a LLVM como módulos separados
- LTO es una opción que realiza inlining y optimización entre codegen units o entre crates al momento del enlace
- Las opciones de LTO en Cargo y
rustcson las siguientes- LTO desactivado
- LTO
"thin" - LTO
"fat" - Si no se especifica, se usa un “thin local LTO” limitado al interior de un solo crate
- En el
Cargo.tomlexistente seguían presentes valores configurados años atráslto = "thin"debug = "full"
debug = "full"activa símbolos completos de depuración que normalmente están deshabilitados en el perfil release- Al medir distintas combinaciones de
ltoydebug, la diferencia fue grande- LTO desactivado,
debug=none: 50.0 segundos / 21.0MiB - Thin local LTO,
debug=full: 88.2 segundos / 256.8MiB - LTO
"thin",debug=full: 172.2 segundos / 197.5MiB - LTO
"fat",debug=full: 287.1 segundos / 155.9MiB
- LTO desactivado,
- Los símbolos completos de depuración aumentaban el tiempo de compilación entre 30% y 50%, y fat LTO tardaba cerca de 4 veces más que desactivar LTO por completo
- Incluso desactivando LTO y símbolos de depuración, seguían haciendo falta unos 50 segundos para compilar un solo binario final
Por qué se mantuvo la caché de Docker en vez de usar compilación incremental
- En desarrollo local, se puede usar compilación incremental montando el directorio
/targeten el Dockerfile como cache mount y conservándolo entre builds - Aun así, se decidió seguir usando
cargo-chefpara preservar la idea de quedocker buildpueda ejecutarse cada vez en un entorno limpio y para aprovechar el sistema de caché propio de Docker
Costo de optimización de LLVM que siguió existiendo después de LTO
- Incluso tras desactivar LTO y símbolos de depuración, compilar el binario final seguía tardando unos 50 segundos
- Al revisar otra vez el self-profile, cerca del 70% del tiempo iba a
LLVM_module_optimize, que es la etapa donde LLVM optimiza el código - Se probó bajar el
opt-level = 3predeterminado del perfil release para optimizar menos solo el binario final- Como las dependencias se cachean, se mantuvo
opt-level = 3enprofile.release.package."*" - Solo se redujo el
opt-leveldel crate final
- Como las dependencias se cachean, se mantuvo
- Los resultados variaron bastante según el nivel de optimización
opt-level=0para el binario final: unos 15 segundosopt-level=1para el binario final: unos 48 segundosopt-level=2o3para el binario final: unos 50~55 segundosopt-level="z"para el binario final: unos 42 segundos
- Con cualquier optimización activada para el binario final aparecía una base de unos 50 segundos; desactivar la optimización por completo lo bajaba a unos 15 segundos
Dificultades para recolectar datos de trazado de LLVM
rustctiene flags para ver información interna de LLVM-Z time-llvm-passes: imprime el perfil de LLVM en texto plano-Z llvm-time-trace: genera el perfil de LLVM en formato de trazado compatible con Chrome
-Z time-llvm-passeschocó con el límite de logs predeterminado de Docker BuildKitBUILDKIT_STEP_LOG_MAX_SIZEBUILDKIT_STEP_LOG_MAX_SPEED
- Estas variables de entorno no se configuran en la invocación de
docker build, sino en el daemon de Docker, y en Linux pueden establecerse endocker.servicemediante un drop-in desystemd - Al quitar el límite, se imprimieron unas 200 mil líneas de texto, demasiado para manejarlo directamente
-Z llvm-time-tracegeneró archivos*.llvm_timings.json, pero el archivo de trazado del binario final era un JSON de una sola línea de 1.4GiB- Firefox Profiler, Perfetto UI y
chrome://tracingde Chromium tuvieron problemas para manejar ese archivo - Se convirtió el JSON a JSONL para poder procesarlo con herramientas comunes
- Separando el arreglo
traceEventsde un único objeto JSON en una línea por evento - Tras la conversión, el número de eventos fue de 7,301,865 líneas
- Separando el arreglo
Cuellos de botella visibles en los eventos de LLVM
- Los eventos de trazado de LLVM eran principalmente complete events con
"ph":"X", y el campodurrepresentaba la duración en microsegundos "ph":"M"correspondía a metadata events, y en este análisis no aportó mucha información útil- Entre los eventos agregados, los que más tiempo consumían eran los siguientes
Total ModuleInlinerWrapperPass: 665.37 segundosTotal ModuleToPostOrderCGSCCPassAdaptor: 656.47 segundosTotal DevirtSCCRepeatedPass: 632.44 segundosTotal OptFunction: 189.62 segundosTotal InlinerPass: 182.25 segundos
- Como esta ejecución tardó unos 110 segundos en una máquina de 16 núcleos, algunos tiempos de pass estaban contabilizados de forma superpuesta
- Los ejes principales eran la optimización de funciones,
OptFunction, y el inlining,InlinerPass
Ajuste de umbrales de inlining
- Las opciones de inlining de LLVM pueden pasarse a través de
-C llvm-argsderustc - A junio de 2025,
rustc -C llvm-args='--help-list-hidden'mostraba alrededor de 100 opciones relacionadas con inlining - Se usaron tres opciones en los experimentos
--inlinedefault-threshold=225--inline-threshold=225--inlinehint-threshold=325
- El threshold permite aproximadamente el inlining de funciones cuyo costo esté por debajo de ese valor, así que al reducirlo disminuye el inlining
- Al bajar los tres umbrales a 50, el tiempo bajó de 48.8 segundos a 42.2 segundos
- Para un caso de uso como un sitio web personal con casi nada de carga, también se veía prometedor un threshold de 10
OptFunction y la monomorfización de genéricos
- En
OptFunction,args.detailcontiene el símbolo mangled de la función que se está optimizando - Con rustfilt se puede demanglear y ver el símbolo original de Rust
__rustc::__rust_allocserde_json::value::to_value
- La razón por la que el mismo
serde_json::value::to_valueaparecía con distintos hashes es que las funciones genéricas se monomorfizan con distintos parámetros de tipo - También se optimizan funciones de otros crates dentro del crate final, porque el lugar donde una función se monomorfiza con tipos concretos es el contexto del crate que la llama
- Algunos ejemplos de funciones con alto costo de optimización fueron
- Un closure dentro de
web_http_server::photos::PhotosState::new - Un closure dentro de
web_http_server::run tokio_postgres::connect_raw- Una función genérica de unas 500 líneas en
pulldown_cmark - Varias especializaciones concretas de
core::ptr::drop_in_place
- Un closure dentro de
- Agrupando de forma aproximada por el nombre del crate externo,
coreera el mayor con 61.53 segundos, y de eso el 84% correspondía a parametrizaciones decore::ptr::drop_in_place
Mangling de símbolos v0 para ubicar mejor las funciones async
- El mangling legacy predeterminado dificultaba distinguir closures
- Al añadir
-C symbol-mangling-version=v0, se veían mejor los números de closure y la información de tipos genéricos - Por ejemplo, se podía ver el conjunto completo de argumentos genéricos con que
serde_json::value::to_valuehabía sido monomorfizado para ciertos tipos deweb_http_server - En la salida v0, los elementos costosos incluían
<web_http_server::photos::PhotosState>::new::{closure#0}: 1.99 segundosweb_http_server::run::{closure#0}: 1.56 segundoscore::ptr::drop_in_place::<axum::routing::Endpoint<web_http_server::AppState>>: 1.22 segundos
- Aunque parecían closures pequeños, al volcar el LLVM IR se vio que las funciones async y los bloques async se representaban internamente como closures anidados
- Rust ya tenía un open issue sobre el mangling relacionado con async function/block
Funciones async grandes y Pin<Box<dyn Future>>
- Los elementos costosos no eran tanto los closures en sí, sino el cuerpo grande de las funciones async
- El tiempo de optimización relacionado con
PhotosState::newera inicialmente de 5.3 segundos en total - El primer intento de simplemente dividir la función solo lo redujo un poco, a 4.66 segundos
- Un intento de agrupar
.awaitadyacentes para reducir la cantidad de.awaitde 10 a 3 incluso empeoró el resultado, subiendo a 6.24 segundos - Como una función async se reduce internamente a una state machine compleja, se probó ocultar esos detalles de implementación al caller borrando el
Futuredetrás de un trait object - La función usada envolvía
impl Future<Output = T>comoPin<Box<dyn Send + Future<Output = T>>> - Aplicándolo en cada punto
.await, como enerase(get_img_candidates()).await?, el resultado fue:- El tiempo relacionado con
PhotosState::newcayó a 2.14 segundos - El tiempo total de compilación, sin profiling, bajó de 48.8 segundos a 46.8 segundos
- El tiempo relacionado con
- También se probaron
#[inline(never)]y desactivar el inlining de funcionespoll, pero no funcionaron tan bien como el boxing
Resultado de combinar varios cambios
- Se aplicaron tres enfoques
- Reducir el inlining mediante args de LLVM
- Dividir funciones costosas del crate principal y usar boxing de Future async
- Reducir genéricos en APIs de dependencias para disminuir lo que se recompila dentro del crate final
- En el Dockerfile final, se aplicó
RUSTFLAGScon los tres thresholds de inlining reducidos a 10 tanto acargo chef cookcomo acargo build - En el crate principal hubo cambios repartidos en 10 archivos, con 898 líneas añadidas y 657 líneas eliminadas
- También se incluyeron cambios del lado de dependencias
- Un PR para hacer no genérica una función genérica de
pulldown-cmark - Crates locales que exponen versiones no genéricas de APIs usadas por
lol_htmlydeadpool_postgres
- Un PR para hacer no genérica una función genérica de
- Con esta combinación, el tiempo final de compilación quedó en 32.3 segundos
Actualización 2025-06-27: -Zshare-generics y salir de Alpine
- A partir de sugerencias recibidas en Bluesky y Lobsters, se probaron además dos cosas
- Activar
-Zshare-generics - Dejar de usar Alpine
- Activar
-Zshare-genericses un flag que reutiliza instancias genéricas de crates dependientes- No está activado por defecto en builds release
- Sí está activado en builds dev del toolchain estable
- Este flag solo puede usarse en nightly
- Al activarlo, el tiempo total de compilación bajó de 32.3 segundos a 29.1 segundos
- Seguían compilándose muchas instancias de
drop_in_place, pero el tiempo de optimización correspondiente se redujo de 21.7 segundos a 17.4 segundos - Al cambiar de Alpine a Debian y eliminar
--target=x86_64-unknown-linux-musl, el tiempo total de compilación bajó drásticamente de 29.1 segundos a 9.1 segundos - Detrás de esta sugerencia estaba la idea de que el allocator predeterminado puede tener un impacto grande en el tiempo de compilación
Cifras finales y tareas pendientes
- Los cambios finales fueron los siguientes
- Punto de partida: unos 175 segundos
- Desactivar LTO y símbolos de depuración: 51 segundos, -71%
opt-level = 1en el crate final: 48.8 segundos, -4%- Reducir inlining con
-C llvm-args: 40.7 segundos, -16% - Cambios en código local: 37.7 segundos, -7%
- Cambios en dependencias: 32.3 segundos, -14%
-Zshare-generics: 29.1 segundos, -10%- Salir de Alpine: 9.1 segundos, -69%
- Durante el análisis, las herramientas y la documentación funcionaron lo bastante bien como para producir mejoras reales
- Aun así, quedan algunos problemas complejos
- El tiempo de compilación de grafos profundos de llamadas async todavía necesita mejorar
- Un tratamiento especial para compilar
core::ptr::drop_in_place<T>en el crate que defineTpodría ayudar en algunos casos, pero es difícil aplicarlo a tipos genéricos y existe el riesgo de compilar incluso drop glue no usado -Zshare-genericsayuda, pero no es una solución completa- Puede hacer falta más tooling para aislar qué partes del codebase consumen más tiempo de compilación y proponer mitigaciones
- En términos prácticos, también puede ser una opción perfectamente válida fijar
opt-level = 0para el crate final
Aún no hay comentarios.