1 puntos por GN⁺ 2025-06-28 | Aún no hay comentarios. | Compartir por WhatsApp
  • 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 rustc internamente y a la etapa de optimización de LLVM
  • Tras aplicar sucesivamente cargo-chef, cargo --timings, -Zself-profile y measureme, 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, como lto = "thin" y debug = "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, usar Pin<Box<dyn Future>> y eliminar genéricos aportó mejoras adicionales
  • Finalmente, al aplicar -Zshare-generics y 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.22 como builder y compilaba para el target x86_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

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 rustc para el crate final web-http-server

cargo --timings y el self-profile de rustc

  • cargo build --release --timings muestra 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 por cargo build
  • Como el cuello de botella estaba concentrado en un solo crate final, cargo --timings por 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 -Z en el compilador estable, se empleó RUSTC_BOOTSTRAP=1
    • Para evitar invalidar la caché de cargo-chef, se usó RUSTFLAGS='-Zself-profile' en vez de cargo rustc -- -Z self-profile
  • Se analizaron los datos del self-profile con las herramientas summarize, flamegraph y crox de measureme
  • Los elementos principales en summarize se concentraban en trabajo relacionado con LLVM
    • LLVM_lto_optimize: 851.95 segundos, 33.389% del total
    • LLVM_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_lto ocupaba 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 rustc son 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.toml existente seguían presentes valores configurados años atrás
    • lto = "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 lto y debug, 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
  • 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 /target en el Dockerfile como cache mount y conservándolo entre builds
  • Aun así, se decidió seguir usando cargo-chef para preservar la idea de que docker build pueda 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 = 3 predeterminado del perfil release para optimizar menos solo el binario final
    • Como las dependencias se cachean, se mantuvo opt-level = 3 en profile.release.package."*"
    • Solo se redujo el opt-level del crate final
  • Los resultados variaron bastante según el nivel de optimización
    • opt-level=0 para el binario final: unos 15 segundos
    • opt-level=1 para el binario final: unos 48 segundos
    • opt-level=2 o 3 para el binario final: unos 50~55 segundos
    • opt-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

  • rustc tiene 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-passes chocó con el límite de logs predeterminado de Docker BuildKit
    • BUILDKIT_STEP_LOG_MAX_SIZE
    • BUILDKIT_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 en docker.service mediante un drop-in de systemd
  • Al quitar el límite, se imprimieron unas 200 mil líneas de texto, demasiado para manejarlo directamente
  • -Z llvm-time-trace generó 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://tracing de Chromium tuvieron problemas para manejar ese archivo
  • Se convirtió el JSON a JSONL para poder procesarlo con herramientas comunes
    • Separando el arreglo traceEvents de 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

Cuellos de botella visibles en los eventos de LLVM

  • Los eventos de trazado de LLVM eran principalmente complete events con "ph":"X", y el campo dur representaba 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 segundos
    • Total ModuleToPostOrderCGSCCPassAdaptor: 656.47 segundos
    • Total DevirtSCCRepeatedPass: 632.44 segundos
    • Total OptFunction: 189.62 segundos
    • Total 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-args de rustc
  • 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.detail contiene 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_alloc
    • serde_json::value::to_value
  • La razón por la que el mismo serde_json::value::to_value aparecí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
  • Agrupando de forma aproximada por el nombre del crate externo, core era el mayor con 61.53 segundos, y de eso el 84% correspondía a parametrizaciones de core::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_value había sido monomorfizado para ciertos tipos de web_http_server
  • En la salida v0, los elementos costosos incluían
    • <web_http_server::photos::PhotosState>::new::{closure#0}: 1.99 segundos
    • web_http_server::run::{closure#0}: 1.56 segundos
    • core::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::new era 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 .await adyacentes para reducir la cantidad de .await de 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 Future detrás de un trait object
  • La función usada envolvía impl Future<Output = T> como Pin<Box<dyn Send + Future<Output = T>>>
  • Aplicándolo en cada punto .await, como en erase(get_img_candidates()).await?, el resultado fue:
    • El tiempo relacionado con PhotosState::new cayó a 2.14 segundos
    • El tiempo total de compilación, sin profiling, bajó de 48.8 segundos a 46.8 segundos
  • También se probaron #[inline(never)] y desactivar el inlining de funciones poll, 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ó RUSTFLAGS con los tres thresholds de inlining reducidos a 10 tanto a cargo chef cook como a cargo 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_html y deadpool_postgres
  • 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
  • -Zshare-generics es 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 = 1 en 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 define T podrí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-generics ayuda, 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 = 0 para el crate final

Aún no hay comentarios.

Aún no hay comentarios.