- Ao fazer builds repetidos de um site feito em Rust com Docker, surgiram problemas de tempo de build
- Na configuração padrão do Docker, ocorre rebuild completo de todas as dependências a cada vez, levando mais de 4 minutos
- Mesmo usando
cargo-chef e ferramentas de cache, o build do binário final ainda consome muito tempo
- O profiling mostrou que a maior parte do tempo é gasta em LTO (otimização em tempo de linkedição) e otimização de módulos LLVM
- Ajustando opções de otimização, informações de debug e configurações de LTO, é possível melhorar parcialmente, mas a compilação do binário final ainda leva no mínimo 50 segundos
Problema e contexto
- Sempre que o autor modificava seu site pessoal feito em Rust, precisava repetir o trabalho incômodo de gerar um binário com linkedição estática, copiá-lo para o servidor e reiniciar o serviço
- Houve a tentativa de migrar para uma implantação baseada em contêineres, como Docker ou Kubernetes, mas a velocidade de build do Rust no Docker se mostrou um grande problema
- Dentro do Docker, mesmo pequenas mudanças no código levavam à necessidade de rebuild completo do zero, gerando ineficiência
Build de Rust no Docker – abordagem básica
- A abordagem comum em um Dockerfile é copiar todas as dependências e o código-fonte e depois executar cargo build
- Nesse caso, não há vantagem real de cache, então o rebuild completo se repete
- No caso do site do autor, o build completo levava cerca de 4 minutos — com tempo extra também para baixar dependências
Melhorando o cache no build do Docker – cargo-chef
- Com a ferramenta
cargo-chef, é possível armazenar em cache previamente as dependências em uma camada separada
- Assim, quando o código muda, o build das dependências pode ser reutilizado, trazendo expectativa de melhora na velocidade de build
- Na prática, apenas 25% do tempo total estava concentrado no build das dependências, e o build do binário final do serviço web ainda consumia bastante tempo (de 2min50s a 3min)
- Mesmo sendo composto por dependências importantes (axum, reqwest, tokio-postgres etc.) e cerca de 7.000 linhas de código próprio, a estrutura resultava em uma única execução de rustc levando 3 minutos
Análise do tempo de build do rustc: cargo --timings
- Com
cargo --timings, é possível ver o tempo de build de cada crate (unidade de compilação)
- O resultado mostrou que o build do binário final ocupava a maior parte do tempo total
- Isso ajuda em uma análise mais detalhada da causa, mas ainda não revela com precisão o funcionamento interno do compilador
Uso do profiling do próprio rustc com -Zself-profile
- O recurso de profiling interno do rustc foi ativado com a flag -Zself-profile para medir detalhadamente o tempo de cada etapa
- Para isso, o profiling foi habilitado por meio de variáveis de ambiente
- Ao analisar com a ferramenta de resumo (
summarize), foi descoberto que LLVM LTO (otimização em tempo de linkedição) e geração de código de módulos LLVM respondiam por mais de 60% do tempo total
- A visualização em flamegraph também mostrou que a etapa codegen_module_perform_lto consumia 80% do tempo total
LTO (otimização em tempo de linkedição) e opções de otimização de build
- O build do Rust é inicialmente dividido por codegen unit, e depois o LTO aplica a otimização global em uma fase relativamente posterior
- O LTO oferece várias opções, como off, thin e fat, cada uma com impacto no desempenho e no artefato final
- No projeto do autor, o
Cargo.toml estava configurado com LTO em thin e símbolos de debug em full
- Ao testar várias combinações de LTO e símbolos de debug, observou-se que:
- símbolos de debug em full aumentavam o tempo de build, e o fat LTO causava um atraso de build cerca de 4 vezes maior
- mesmo removendo LTO e símbolos de debug, o tempo mínimo de build ainda era de 50 segundos
Otimizações adicionais e observações
- Cerca de 50 segundos não era um grande problema para o site do autor, que quase não tinha carga real de serviço, mas a curiosidade técnica motivou uma análise adicional
- A compilação incremental (incremental compilation), se bem aproveitada com Docker, poderia tornar os builds mais rápidos, mas isso exige combinar limpeza do ambiente de build com o cache do Docker
Profiling detalhado da etapa LLVM
- Mesmo após remover LTO e símbolos de debug, a etapa LLVM_module_optimize ainda consumia quase 70% do tempo
- Foi percebido que o custo de otimização vinha do valor padrão de opt-level (3) no perfil release, então foi testada a redução do nível de otimização apenas no binário
- Os experimentos com várias combinações mostraram que, sem otimização (
opt-level=0), o tempo ficava em torno de 15 segundos; com otimização aplicada (1 a 3), ficava em torno de 50 segundos
Análise aprofundada dos eventos de rastreamento do LLVM
- Com flags adicionais do rustc (
-Z time-llvm-passes, -Z llvm-time-trace), é possível rastrear em detalhe o tempo de execução de cada etapa do LLVM
-Z time-llvm-passes gera uma saída tão grande que muitas vezes ultrapassa o limite de logs do Docker, exigindo ajustes na configuração de logs
- Salvando os logs em arquivo para análise, é possível verificar individualmente o tempo de execução de cada pass de otimização do LLVM
- A opção
-Z llvm-time-trace gera uma enorme saída JSON no formato chrome tracing, e o arquivo fica tão grande que ferramentas comuns de edição e análise de texto se tornam difíceis de usar
- Ao dividir esse conteúdo por linha em formato jsonl, a análise passa a ser viável em ambiente de CLI/script
Principais insights e conclusão
- Ao fazer build de projetos Rust complexos com Docker, o gargalo de velocidade está principalmente no build do binário final e nas etapas associadas de otimização do LLVM
- Ao ajustar LTO, símbolos de debug e opt-level, fica claro o trade-off entre tempo de build e tamanho do binário
- Com ajustes mais agressivos nas opções de otimização, é possível reduzir bastante o tempo de build, embora a ausência de otimizações possa trazer perda de desempenho
- Se a eficiência de build for importante em ambientes com muitas dependências de crates e uso comercial, uma boa estratégia é usar profiling ativamente para identificar com precisão os gargalos detalhados
- Ao projetar um pipeline de build em Rust, é necessário um planejamento cuidadoso da combinação entre LTO, opt-level, símbolos de debug e estratégia de cache
1 comentários
Comentários no Hacker News
O projeto Rust às vezes parece pequeno à primeira vista, o que acho interessante. Primeiro, dependências não estão ligadas ao tamanho real da base de código. Em C++, projetos grandes muitas vezes incorporam dependências ao repositório ou nem as usam, então se 400 mil linhas compilam devagar dá para pensar "tem muito código, então é natural que seja lento". Segundo, a parte muito mais problemática são os macros. Um macro que se expande repetidamente em blocos de 10 ou 100 linhas pode transformar rapidamente um projeto de 10 mil linhas em um de 1 milhão. Terceiro, há os genéricos. Cada instanciação de genérico consome recursos de CPU. Ainda assim, para defender um pouco a ideia, esses recursos também trazem a vantagem de reduzir algo que teria 100 mil linhas em C ou 25 mil em C++ para apenas alguns milhares em Rust. Mas também é verdade que o uso excessivo desses recursos faz o ecossistema parecer lento. Por exemplo, na nossa empresa usamos async-graphql; a biblioteca em si é excelente, mas depende demais de macros procedurais. Há issues de desempenho abertas há anos, e toda vez que adicionamos um tipo de dado dá para sentir claramente o compilador ficando mais lento
Ryan Fleury fez o Epic RAD Debugger em C, com 278 mil linhas, usando um estilo de build unity (todo o código em um único arquivo, como uma única unidade de compilação), e no Windows a compilação limpa leva só 1,5 segundo. Só esse caso já mostra como compilação pode ser extremamente rápida, então fico curioso por que não dá para fazer algo parecido em Rust ou Swift
foo(int)em vez defoo(char*)são devidamente verificadasSou muito grato por Go ter priorizado velocidade de compilação em vez de otimização. Para trabalho de servidor, rede e glue code, compilar muito rápido é mais importante que qualquer coisa. Também quero um nível razoável de segurança de tipos, mas sem atrapalhar a prototipagem solta. O fato de ter GC também é conveniente. Acho que, depois da experiência de escalar desenvolvimento no Google, chegaram à conclusão de que tipos simples, GC e compilação extremamente rápida importam muito mais do que velocidade de execução ou perfeição semântica. Basta olhar os grandes exemplos de software de rede e infraestrutura feitos em Go para ver que a escolha foi certeira. Claro, em ambientes onde GC é inaceitável ou precisão absoluta importa mais, talvez Go não sirva, mas no meu ambiente de trabalho as escolhas do Go são ideais
Não consigo entender a alegação de que instalar um binário estático único é mais simples do que gerenciar contêineres
No meu notebook (Mac M4 Pro), uma compilação completa do Deno (um grande projeto em Rust) leva 2 minutos. Pelos comandos, debug leva cerca de 1 min 54 s e release cerca de 8 min 17 s. Foram medições sem compilação incremental. Na prática, builds de deploy rodam no sistema de CI/CD, então eu nem preciso esperar isso diretamente
Onde entra a conversa sobre Cranelift? Na minha opinião, eu quase desisti de fazer jogos em Rust por causa do tempo de compilação longo demais. Investigando, descobri que o LLVM é lento independentemente do nível de otimização. Os desenvolvedores da linguagem Jai sempre apontaram isso. Já tive a experiência de reduzir o tempo de build de 16 segundos para 4 segundos com Cranelift. Impressionante o trabalho da equipe do Cranelift!
Não acho que Rust seja lento. Em comparação com linguagens equivalentes, ele é rápido o bastante e, perto de compilações de C++/Scala que levavam 15 minutos, é muito melhor
Como ex-desenvolvedor C++, não entendo muito bem a afirmação de que builds em Rust são lentos
Compilação incremental é realmente muito poderosa. Depois do build inicial, dá para congelar um snapshot do cache incremental e, se não houver mudanças, reutilizar isso para builds e deploys rápidos. Também combina bem com Docker. Tirando mudanças de versão do compilador ou grandes atualizações do site, você nem toca nas camadas do build de imagem. Se configurar para que apenas mudanças de código não rebuildem aquela camada, fica bem eficiente
O tempo de build da minha homepage é 73 ms. O static site generator recompila em apenas 17 ms. A execução real do generator leva só 56 ms. Segue o log de build do Zig
cargo watch. Com coisas como subsecond[0], que incluem incremental linking e hotpatch, fica ainda mais rápido. Não chega ao Zig, mas fica quase lá. Se os 331 ms mencionados acima forem de um build clean (sem cache), então é muito mais rápido do que o build clean de 12 segundos do meu site. [0]: https://news.ycombinator.com/item?id=44369642