1 pontos por GN⁺ 2025-06-28 | 1 comentários | Compartilhar no WhatsApp
  • 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

 
GN⁺ 2025-06-28
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

    • Fico curioso sobre por que tantos casos de reescrita em Rust acontecem justamente em lugares onde o código original já era simples, como pequenos utilitários em C. Vejo isso com mais frequência do que portar grandes programas em C de 100 mil linhas para Rust. Queria saber como Rust e C se comparam na velocidade de compilação de programas pequenos. Não estou perguntando sobre o tamanho do programa, mas sobre a velocidade de compilação. Como referência, em medições recentes, o tamanho do toolchain do compilador Rust foi cerca de 2x o GCC que eu uso. 1. Programas tão pequenos assim, em qualquer linguagem, tendem a ter menos chance de esconder problemas de segurança de memória, e como a escala é pequena também são mais fáceis de auditar. É uma situação diferente de um programa em C com 100 mil linhas
    • Dá para sentir na pele que o compilador fica mais lento cada vez que um novo tipo é definido. Pelo que lembro, o desempenho do compilador piora de forma exponencial conforme a “profundidade” dos tipos. Em GraphQL isso é especialmente grave, porque há muitos tipos aninhados
    • Para lidar com o problema de macros que se expandem em dezenas ou centenas de linhas e podem fazer a base de código crescer geometricamente, suporte recente foi adicionado às ferramentas de análise. Material relacionado: https://nnethercote.github.io/2025/06/26/how-much-code-does-that-proc-macro-generate.html
  • 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

    • Quanto mais trabalho o compilador faz em tempo de build, mais longo fica o build. Go consegue builds abaixo de 1 segundo mesmo em bases grandes. Ele tem um sistema de módulos e de tipos simples, minimiza o que precisa ser feito no build e deixa a maior parte a cargo do GC em tempo de execução. Em contrapartida, se você exige macros, sistema de tipos complexo e alto nível de robustez, o build inevitavelmente fica mais lento
    • Rust também usa o crate inteiro como unidade de build, e o compilador divide isso em um tamanho apropriado em LLVM IR. Ele também equilibra sozinho o trabalho duplicado e o incremental build. Em linhas de código-fonte, Rust muitas vezes compila mais rápido que C++. Só que projetos em Rust têm a característica de compilar também todas as dependências
    • O motivo de Rust e Swift compilarem mais devagar que compiladores C é que a própria linguagem exige muito mais análise. Por exemplo, o borrow checker do Rust não vem de graça. Só as verificações em tempo de compilação já consomem bastante recurso. C é rápido porque praticamente não verifica nada além da sintaxe básica. Na verdade, em C nem combinações estranhas como chamar foo(int) em vez de foo(char*) são devidamente verificadas
    • Compilei projetos C++ de dezenas de milhares de linhas nos anos 2000, e mesmo em computadores antigos o build terminava em menos de 1 segundo. Por outro lado, um HELLO WORLD usando Boost levava vários segundos. No fim, a velocidade de build depende muito não só da linguagem e do compilador, mas da estrutura do código e dos recursos usados. Dá para fazer DOOM com macros em C, mas provavelmente não seria rápido. E, ao contrário, também dá para estruturar Rust para builds rápidos
    • Não é muito surpreendente que linguagens como C e Go, que priorizam compilação rápida, sejam rápidas. O realmente difícil é compilar rapidamente a semântica do Rust. Esse problema inclusive aparece no FAQ oficial do Rust
  • Sou 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

    • Também gosto de Go, mas não acho que essa linguagem seja produto de alguma grande inteligência coletiva organizacional do Google. Se a experiência do Google estivesse de fato embutida ali, teriam adicionado coisas como eliminação estática de exceções por ponteiro nulo. Parece mais o resultado de alguns desenvolvedores do Google criando a linguagem que eles próprios queriam
    • Go tem vantagens como compilação rápida, sistema de tipos razoável e GC, mas esse espaço de design já era ocupado de forma parecida pelo Java. Parece que a criação do Go veio principalmente de impulso criativo e, no fim, acabou sendo mais absorvida por usuários de linguagens de script (Python/Ruby/JS) do que pelo alvo original (C/C++/Java no lado servidor). Usuários de script só queriam um sistema de tipos simples e rápido, e Java era velho e sem graça demais. No espaço de servidores/conferências/bibliotecas, Java já não tinha mais espaço
    • Também há a história de que desenvolvedores do Google desenharam Go enquanto esperavam compilar projetos C++
    • Gostaria de perguntar o que seria exatamente um "obnoxious type". Um tipo ou representa os dados corretamente ou não representa; na prática, em qualquer linguagem é possível forçar o verificador de tipos a ficar quieto
    • Go é uma linguagem muito alinhada ao objetivo de projeto e ao uso real dela. O maior risco está em compartilhamento de estado mutável com paralelismo via canais, onde podem surgir bugs sutis ou frágeis. Em geral, a maioria dos usuários nem usa esse padrão. Eu uso Rust, e meu trabalho envolve extrair o máximo possível de algoritmos lentos em hardware lento. Por isso, paralelização em larga escala é um problema muito sutilmente inviável
  • Não consigo entender a alegação de que instalar um binário estático único é mais simples do que gerenciar contêineres

    • Parece que não entenderam claramente o que o Docker de fato faz. Por exemplo, foi dito que "se você distribui como imagem Docker precisa rebuildar tudo do zero toda vez", mas em ambientes internos de build/deploy isso nem precisa ser um problema. Para uso pessoal, também dá para manter a conveniência no desenvolvimento e simplesmente colocar no contêiner o arquivo compilado localmente. Só é preciso tomar cuidado com caminhos que revelem vestígios do ambiente de build. Em CI/CD ou projetos em equipe, a ênfase está em garantir builds reproduzíveis do zero em qualquer lugar, mas em trabalho individual isso não é necessário
    • No texto original, o objetivo não era simplificar, e sim modernizar. A ideia era: "como a maior parte do software nos últimos 10 anos adotou deploy em contêiner como padrão, também vou implantar meu site com contêineres como Docker e Kubernetes". Contêineres trazem várias vantagens, como isolamento de processos, segurança, logging padronizado e escalabilidade horizontal
  • 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!

    • No recente Bevy game jam usei uma ferramenta chamada 'subsecond', vinda da comunidade Dioxus, e ela realmente permitiu hot reload de sistemas em menos de 1 segundo, o que ajudou muito na prototipagem de UI. https://github.com/TheBevyFlock/bevy_simple_subsecond_system
    • Pelo que sei, o time do Zig também está tentando acelerar muito o tempo de build criando um compilador próprio (backend) sem LLVM
    • Eu achava que o Cranelift antes não suportava macOS aarch64, mas descobri recentemente que agora suporta
    • Dizer que quase desistiu de Rust por causa de 16 segundos de build não é um pouco exagerado?
  • 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

    • Também concordo. Nunca senti que build de Rust fosse especialmente inconveniente. Talvez a má fama do começo tenha continuado e criado essa impressão
    • O uso de memória durante a compilação é muito maior que em C/C++. Para compilar projetos grandes em Rust numa VM que uso para demos no YouTube, preciso de mais de 8 GB. Em C/C++ nunca preciso me preocupar com isso
    • Como templates de C++ são Turing-completos, comparar apenas tempo de build sem considerar o estilo real de código não faz muito sentido
  • Como ex-desenvolvedor C++, não entendo muito bem a afirmação de que builds em Rust são lentos

    • É por isso que dizem que Rust mira em desenvolvedores C++. Quem tem muita experiência com C++ já aguenta desconforto de ferramentas por puro Stockholm syndrome
    • Mesmo que seja mais rápido que C++, ainda pode ser lento em termos absolutos. A má fama dos builds em C++ já é amplamente conhecida de tão terrível que é. Como Rust não carrega esse tipo de problema estrutural de linguagem, a expectativa acaba sendo maior
    • Parece um caso clássico de continuar adicionando funcionalidades novas sem realmente ouvir usuários e resolver os problemas deles
    • C era rápido porque tinha poucas etapas de compilação e tudo era simples, mas sinto que C++, com o uso de templates, acabou destruindo a maior parte do trabalho de encapsulamento. Basta mudar um único header de template e parece que 98% do projeto inteiro é afetado
  • 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

    • Os artefatos incrementais do meu projeto já passam de 150 GB. Quando usei imagens Docker desse tamanho, isso causou problemas realmente grandes
  • 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

    • Parece que sempre existe um comentário dizendo que Rust é bom em discussões sobre C/C++, e outro dizendo que Zig é bom em discussões sobre Rust. (Depois descobri que o autor desse comentário é o principal desenvolvedor do Zig.) Acho que evangelização de linguagem faz mal à comunidade e, na prática, só gera antipatia em vez de atrair novos usuários. Se você realmente ama a linguagem, ajuda mais conter essa cultura de evangelização
    • Em vez de apenas apresentar um único número de tempo de compilação, teria sido melhor haver uma discussão ou interpretação diretamente ligada ao tema do texto original
    • Meu site em Rust (incluindo framework no estilo React e um webserver de verdade) também leva cerca de 1,25 segundo com build incremental via 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
    • Queria muito perguntar ao @AndyKelley qual ele acha que é o motivo decisivo para o Zig compilar extremamente rápido enquanto Rust e Swift são sempre lentos
    • O Zig não é uma linguagem que não garante segurança de memória?