Por que o compilador Rust é tão lento?
(sharnoff.io)- Mesmo com cache de dependências em um build de um site Rust para distribuição via Docker, apenas o crate final leva cerca de 175 segundos, e o gargalo foi reduzido ao interior do
rustce à etapa de otimização do LLVM - Ao aplicar, em sequência,
cargo-chef,cargo --timings,-Zself-profileemeasureme, o resultado mostrou que não era um simples problema de dependências: o custo de LTO e geração de código do LLVM dominava o tempo de build - As configurações antigas no
Cargo.toml,lto = "thin"edebug = "full", tiveram grande impacto; ao desativar ambas, o build do binário final caiu de 172,2 segundos para cerca de 50 segundos - Nos rastros do LLVM,
OptFunction,InlinerPass,core::ptr::drop_in_place, grandes funções async e a monomorfização de genéricos apareceram como os principais custos; reduzir inlining, separar funções, usarPin<Box<dyn Future>>e remover genéricos trouxeram melhorias adicionais - Por fim, ao aplicar
-Zshare-genericse migrar para um build baseado em Debian, o tempo de compilação caiu de 29,1 segundos para 9,1 segundos, mostrando que não só a estrutura do código, mas também o allocator e o uso ou não do alvo musl têm grande efeito no tempo de build
Gargalo revelado no build Docker
- O site é fornecido principalmente como um único binário Rust; antes, era feito o build de um binário com linkagem estática, copiado para o servidor e o serviço era reiniciado
- Ao tentar migrar para uma distribuição baseada em contêineres, configurar builds Rust rápidos no Docker se mostrou mais difícil do que o esperado
- O Dockerfile básico recompila tudo sempre que o código-fonte muda
- Usa
rust:1.87-alpine3.22como builder e compila para o alvox86_64-unknown-linux-musl - A imagem final copia apenas o binário para Alpine
- Um build limpo nesse formato leva 3 minutos e 51 segundos, incluindo 10 segundos para baixar crates
- Usa
cargo-chef separou o cache de dependências, mas não foi suficiente
- cargo-chef cria um arquivo recipe simplificado a partir do workspace e, com base nele, compila previamente as dependências em uma camada separada de cache do Docker
- Como o site usa centenas de dependências, esperava-se um grande efeito de cache
- Na medição real, o build das dependências levou 1 minuto e 7 segundos, e o build do binário final usando as dependências em cache levou 2 minutos e 50 segundos
- Apenas cerca de 25% do tempo total foi gasto nas dependências; a maior parte do restante foi consumida em uma única chamada ao
rustcpara o crate finalweb-http-server
cargo --timings e self-profile do rustc
cargo build --release --timingsmostra o tempo de compilação por crate, e o tempo do crate final foi de 174,1 segundos, aproximadamente consistente com os 2 minutos e 54 segundos exibidos porcargo build- Como o gargalo estava concentrado em um único crate final, só o
cargo --timingsnão bastava para entender a causa detalhada - Para usar o recurso de self-profile do
rustc, foi usado-Zself-profile- Para usar a flag instável
-Zno compilador estável, foi usadoRUSTC_BOOTSTRAP=1 - Para evitar invalidar o cache do
cargo-chef, foi usadoRUSTFLAGS='-Zself-profile'em vez decargo rustc -- -Z self-profile
- Para usar a flag instável
- Os dados de self-profile foram analisados com as ferramentas
summarize,flamegraphecroxdo measureme - Os principais itens em
summarizeestavam concentrados em tarefas relacionadas ao LLVMLLVM_lto_optimize: 851,95 segundos, 33,389% do 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%
- No flamegraph,
codegen_module_perform_ltorespondia por cerca de 80% do tempo total
Impacto das configurações de LTO e símbolos de debug
- O compilador Rust divide o crate em codegen units e as entrega ao LLVM como módulos separados
- LTO é uma opção que realiza inlining e otimizações entre codegen units ou crates no momento da linkagem
- As opções de LTO do Cargo e do
rustcsão as seguintes- LTO desativado
- LTO
"thin" - LTO
"fat" - Se nada for especificado, “thin local LTO”, limitado a um único crate
- O
Cargo.tomlexistente ainda continha valores configurados alguns anos anteslto = "thin"debug = "full"
debug = "full"ativa símbolos de debug completos, que por padrão são excluídos no perfil release- As medições de várias combinações de
ltoedebugmostraram diferenças grandes- LTO desativado,
debug=none: 50,0 segundos / 21,0 MiB - Thin local LTO,
debug=full: 88,2 segundos / 256,8 MiB - LTO
"thin",debug=full: 172,2 segundos / 197,5 MiB - LTO
"fat",debug=full: 287,1 segundos / 155,9 MiB
- LTO desativado,
- Símbolos de debug completos aumentaram o tempo de compilação em 30% a 50%, e fat LTO levou cerca de 4 vezes mais tempo do que desativar LTO completamente
- Mesmo desativando LTO e símbolos de debug, ainda restavam cerca de 50 segundos para compilar um único binário final
Por que manter o cache do Docker em vez de compilação incremental
- No desenvolvimento local, é possível usar compilação incremental conectando o diretório
/targetcomo um cache mount no Dockerfile e preservando-o entre builds - Porém, para manter a possibilidade de o
docker buildter sempre um ambiente limpo e aproveitar o próprio sistema de cache do Docker, ocargo-chefcontinuou sendo usado
Custo restante de otimização do LLVM após LTO
- Mesmo após desativar LTO e símbolos de debug, a compilação do binário final ainda levava cerca de 50 segundos
- Olhando novamente o self-profile, cerca de 70% do tempo era gasto em
LLVM_module_optimize, que é a etapa em que o LLVM otimiza o código - Foi testada uma configuração que reduz o
opt-level = 3padrão do perfil release apenas para o binário final- Como as dependências estão em cache,
opt-level = 3é mantido emprofile.release.package."*" - Apenas o
opt-leveldo crate final é reduzido
- Como as dependências estão em cache,
- Os resultados variaram bastante conforme a otimização estava ou não ativada
opt-level=0no final: cerca de 15 segundosopt-level=1no final: cerca de 48 segundosopt-level=2ou3no final: cerca de 50 a 55 segundosopt-level="z"no final: cerca de 42 segundos
- Ativar qualquer otimização no binário final cria uma linha de base de cerca de 50 segundos; ao desativar totalmente a otimização, o tempo cai para cerca de 15 segundos
Dificuldades para coletar dados de tracing do LLVM
- O
rustctem flags para visualizar informações do LLVM-Z time-llvm-passes: imprime informações de perfil do LLVM em texto comum-Z llvm-time-trace: gera o perfil do LLVM no formato de tracing do Chrome
-Z time-llvm-passesesbarrou no limite padrão de logs do Docker BuildKitBUILDKIT_STEP_LOG_MAX_SIZEBUILDKIT_STEP_LOG_MAX_SPEED
- Essas variáveis de ambiente precisam ser configuradas no daemon do Docker, não na chamada de
docker build; no Linux, isso pode ser feito emdocker.servicecom um drop-in dosystemd - Ao remover o limite, foram impressas cerca de 200 mil linhas de texto, difíceis de manipular diretamente
-Z llvm-time-tracegerou arquivos*.llvm_timings.json, mas o arquivo de trace do binário final era um JSON de linha única com 1,4 GiB- Firefox Profiler, Perfetto UI e
chrome://tracingdo Chromium tiveram problemas para lidar com esse arquivo - O JSON foi convertido para JSONL para processamento com ferramentas comuns
- O array
traceEventsde um único objeto JSON foi separado em uma linha por evento - Após a conversão, o número de eventos foi de 7.301.865 linhas
- O array
Gargalos vistos nos eventos do LLVM
- Os eventos de trace do LLVM eram principalmente complete events com
"ph":"X", e o campodurindicava a duração em microssegundos "ph":"M"era metadata event e não trazia muitas informações úteis para esta análise- Nos eventos agregados, os itens que consumiam muito tempo foram os seguintes
Total ModuleInlinerWrapperPass: 665,37 segundosTotal ModuleToPostOrderCGSCCPassAdaptor: 656,47 segundosTotal DevirtSCCRepeatedPass: 632,44 segundosTotal OptFunction: 189,62 segundosTotal InlinerPass: 182,25 segundos
- Como essa execução levou cerca de 110 segundos em uma máquina com 16 núcleos, parte do tempo dos passes foi contado de forma sobreposta
- Os grandes eixos eram
OptFunction, otimização de funções, eInlinerPass, inlining
Ajuste dos thresholds de inlining
- As opções de inlining do LLVM podem ser passadas pelo
-C llvm-argsdorustc - Em junho de 2025,
rustc -C llvm-args='--help-list-hidden'mostrava cerca de 100 opções relacionadas a inlining - Três opções foram usadas no experimento
--inlinedefault-threshold=225--inline-threshold=225--inlinehint-threshold=325
- O threshold, de forma aproximada, permite o inlining de funções cujo custo seja menor do que esse valor; reduzir o valor diminui o inlining
- Ao baixar os três thresholds para 50, o tempo caiu de 48,8 segundos para 42,2 segundos
- Para o caso de uso de um site pessoal com quase nenhuma carga, o threshold 10 também foi considerado uma opção promissora
OptFunction e monomorfização de genéricos
- O
args.detaildos eventosOptFunctioncontém o símbolo com mangling da função que está sendo otimizada - Ao fazer demangle com rustfilt, é possível ver os símbolos Rust originais
__rustc::__rust_allocserde_json::value::to_value
- O motivo de o mesmo
serde_json::value::to_valueaparecer com vários hashes é que funções genéricas são monomorfizadas com parâmetros de tipo diferentes - Funções de outros crates também são otimizadas no crate final, porque o local onde uma função é monomorfizada para um tipo específico é o contexto do crate que a chama
- Exemplos de funções que consumiram muito tempo de otimização
- closure dentro de
web_http_server::photos::PhotosState::new - closure dentro de
web_http_server::run tokio_postgres::connect_raw- função genérica de cerca de 500 linhas em
pulldown_cmark - vários tipos concretos de
core::ptr::drop_in_place
- closure dentro de
- Agregando de forma aproximada pelo nome do crate externo,
corefoi o maior, com 61,53 segundos, e 84% disso vinha da parametrização decore::ptr::drop_in_place
Localizando funções async com mais clareza usando mangling de símbolos v0
- O legacy symbol mangling padrão dificultava distinguir closures
- Adicionar
-C symbol-mangling-version=v0revela melhor os números das closures e as informações dos tipos genéricos - Por exemplo, foi possível ver todos os argumentos genéricos que indicavam com qual tipo de
web_http_serverserde_json::value::to_valuehavia sido monomorfizada - Na saída v0, os itens caros foram os seguintes
<web_http_server::photos::PhotosState>::new::{closure#0}: 1,99 segundoweb_http_server::run::{closure#0}: 1,56 segundocore::ptr::drop_in_place::<axum::routing::Endpoint<web_http_server::AppState>>: 1,22 segundo
- Embora parecessem closures pequenas, ao despejar o LLVM IR percebeu-se que funções async e blocos async eram representados internamente como closures aninhadas
- Rust já tinha uma issue aberta relacionada ao mangling de funções/blocos async
Grandes funções async e Pin<Box<dyn Future>>
- Os itens caros não eram exatamente as closures em si, mas o corpo de grandes funções async
- O tempo de otimização relacionado a
PhotosState::newinicialmente era de 5,3 segundos no total - A primeira tentativa de simplesmente dividir a função reduziu apenas um pouco, para 4,66 segundos
- A tentativa de agrupar
.awaitadjacentes e reduzir o número de.awaitde 10 para 3 aumentou o tempo para 6,24 segundos - Como funções async são reduzidas internamente a uma state machine complexa, foi tentado apagar os detalhes da implementação no caller usando
Futurecomo trait object - A função usada tinha o formato de envolver
impl Future<Output = T>emPin<Box<dyn Send + Future<Output = T>>> - Aplicando isso em cada ponto de
.await, comoerase(get_img_candidates()).await?, o resultado foi:- O tempo relacionado a
PhotosState::newcaiu para 2,14 segundos - O tempo total de build, sem profiling, caiu de 48,8 segundos para 46,8 segundos
- O tempo relacionado a
#[inline(never)]e desativar o inlining da função poll também foram tentados, mas não foram tão bons quanto o boxing
Resultado da combinação de várias mudanças
- As abordagens aplicadas foram três
- redução de inlining via LLVM args
- separação de funções caras no crate principal e boxing de async Future
- redução de genéricos nas APIs das dependências para diminuir partes recompiladas no crate final
- No Dockerfile final,
RUSTFLAGSreduzindo os três thresholds de inlining para 10 foi aplicado tanto acargo chef cookquanto acargo build - No crate principal, houve 898 linhas adicionadas e 657 linhas removidas em 10 arquivos
- Mudanças nas dependências também entraram junto
- PR para tornar não genérica uma função genérica de
pulldown-cmark - crate local expondo versões não genéricas de APIs usadas em
lol_htmledeadpool_postgres
- PR para tornar não genérica uma função genérica de
- Com essa combinação, o tempo final de compilação ficou em 32,3 segundos
Atualização de 2025-06-27: -Zshare-generics e remoção do Alpine
- Com sugestões recebidas no Bluesky e no Lobsters, mais dois experimentos foram feitos
- Ativar
-Zshare-generics - Sair do Alpine
- Ativar
-Zshare-genericsé uma flag que reutiliza instâncias genéricas das dependências de crates- Não é ativada por padrão em builds release
- Está ativada em dev builds da stable toolchain
- Essa flag só pode ser usada no nightly
- Ao ativar
-Zshare-generics, o tempo total de compilação caiu de 32,3 segundos para 29,1 segundos - Muitas instâncias de
drop_in_placeainda foram compiladas, mas o tempo de otimização correspondente caiu de 21,7 segundos para 17,4 segundos - Ao trocar Alpine por Debian e remover
--target=x86_64-unknown-linux-musl, o tempo total de compilação caiu fortemente, de 29,1 segundos para 9,1 segundos - O contexto da sugestão era que o allocator padrão pode ter grande impacto no tempo de build
Números finais e pontos em aberto
- A evolução final foi a seguinte
- Ponto de partida: cerca de 175 segundos
- Desativação de LTO e símbolos de debug: 51 segundos, -71%
opt-level = 1no crate final: 48,8 segundos, -4%- Redução de inlining com
-C llvm-args: 40,7 segundos, -16% - Mudanças no código local: 37,7 segundos, -7%
- Mudanças em dependências: 32,3 segundos, -14%
-Zshare-generics: 29,1 segundos, -10%- Remoção do Alpine: 9,1 segundos, -69%
- Durante a análise, as ferramentas e a documentação funcionaram bem o suficiente para gerar melhorias reais
- Ainda assim, alguns problemas complexos permanecem
- O tempo de compilação de grafos profundos de chamadas de funções async ainda precisa melhorar
- Tratar de forma especial
core::ptr::drop_in_place<T>para compilá-lo no crate que defineTpode ajudar em alguns casos, mas é difícil de aplicar a tipos genéricos e há o risco de compilar drop glue que não será usado -Zshare-genericsajuda, mas não é uma solução completa- Podem ser necessárias mais ferramentas para isolar quais partes da base de código consomem muito tempo de compilação e sugerir mitigações
- Na prática, configurar
opt-level = 0no crate final também pode ser uma escolha suficiente
Ainda não há comentários.