1 pontos por GN⁺ 2025-06-28 | Ainda não há comentários. | Compartilhar no WhatsApp
  • 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 rustc e à etapa de otimização do LLVM
  • Ao aplicar, em sequência, cargo-chef, cargo --timings, -Zself-profile e measureme, 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" e debug = "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, usar Pin<Box<dyn Future>> e remover genéricos trouxeram melhorias adicionais
  • Por fim, ao aplicar -Zshare-generics e 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.22 como builder e compila para o alvo x86_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

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

cargo --timings e self-profile do rustc

  • cargo build --release --timings mostra 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 por cargo build
  • Como o gargalo estava concentrado em um único crate final, só o cargo --timings nã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 -Z no compilador estável, foi usado RUSTC_BOOTSTRAP=1
    • Para evitar invalidar o cache do cargo-chef, foi usado RUSTFLAGS='-Zself-profile' em vez de cargo rustc -- -Z self-profile
  • Os dados de self-profile foram analisados com as ferramentas summarize, flamegraph e crox do measureme
  • Os principais itens em summarize estavam concentrados em tarefas relacionadas ao LLVM
    • LLVM_lto_optimize: 851,95 segundos, 33,389% do 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%
  • No flamegraph, codegen_module_perform_lto respondia 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 rustc são as seguintes
    • LTO desativado
    • LTO "thin"
    • LTO "fat"
    • Se nada for especificado, “thin local LTO”, limitado a um único crate
  • O Cargo.toml existente ainda continha valores configurados alguns anos antes
    • lto = "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 lto e debug mostraram 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
  • 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 /target como um cache mount no Dockerfile e preservando-o entre builds
  • Porém, para manter a possibilidade de o docker build ter sempre um ambiente limpo e aproveitar o próprio sistema de cache do Docker, o cargo-chef continuou 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 = 3 padrão do perfil release apenas para o binário final
    • Como as dependências estão em cache, opt-level = 3 é mantido em profile.release.package."*"
    • Apenas o opt-level do crate final é reduzido
  • Os resultados variaram bastante conforme a otimização estava ou não ativada
    • opt-level=0 no final: cerca de 15 segundos
    • opt-level=1 no final: cerca de 48 segundos
    • opt-level=2 ou 3 no final: cerca de 50 a 55 segundos
    • opt-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 rustc tem 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-passes esbarrou no limite padrão de logs do Docker BuildKit
    • BUILDKIT_STEP_LOG_MAX_SIZE
    • BUILDKIT_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 em docker.service com um drop-in do systemd
  • Ao remover o limite, foram impressas cerca de 200 mil linhas de texto, difíceis de manipular diretamente
  • -Z llvm-time-trace gerou 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://tracing do Chromium tiveram problemas para lidar com esse arquivo
  • O JSON foi convertido para JSONL para processamento com ferramentas comuns
    • O array traceEvents de 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

Gargalos vistos nos eventos do LLVM

  • Os eventos de trace do LLVM eram principalmente complete events com "ph":"X", e o campo dur indicava 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 segundos
    • Total ModuleToPostOrderCGSCCPassAdaptor: 656,47 segundos
    • Total DevirtSCCRepeatedPass: 632,44 segundos
    • Total OptFunction: 189,62 segundos
    • Total 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, e InlinerPass, inlining

Ajuste dos thresholds de inlining

  • As opções de inlining do LLVM podem ser passadas pelo -C llvm-args do rustc
  • 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.detail dos eventos OptFunction conté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_alloc
    • serde_json::value::to_value
  • O motivo de o mesmo serde_json::value::to_value aparecer 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
  • Agregando de forma aproximada pelo nome do crate externo, core foi o maior, com 61,53 segundos, e 84% disso vinha da parametrização de core::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=v0 revela 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_server serde_json::value::to_value havia sido monomorfizada
  • Na saída v0, os itens caros foram os seguintes
    • <web_http_server::photos::PhotosState>::new::{closure#0}: 1,99 segundo
    • web_http_server::run::{closure#0}: 1,56 segundo
    • core::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::new inicialmente 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 .await adjacentes e reduzir o número de .await de 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 Future como trait object
  • A função usada tinha o formato de envolver impl Future<Output = T> em Pin<Box<dyn Send + Future<Output = T>>>
  • Aplicando isso em cada ponto de .await, como erase(get_img_candidates()).await?, o resultado foi:
    • O tempo relacionado a PhotosState::new caiu para 2,14 segundos
    • O tempo total de build, sem profiling, caiu de 48,8 segundos para 46,8 segundos
  • #[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, RUSTFLAGS reduzindo os três thresholds de inlining para 10 foi aplicado tanto a cargo chef cook quanto a cargo 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_html e deadpool_postgres
  • 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
  • -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_place ainda 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 = 1 no 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 define T pode 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-generics ajuda, 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 = 0 no crate final também pode ser uma escolha suficiente

Ainda não há comentários.

Ainda não há comentários.