2 pontos por GN⁺ 2023-11-11 | 1 comentários | Compartilhar no WhatsApp
  • A paralelização do compilador Rust vinha dependendo principalmente do Cargo e do backend LLVM, mas agora a execução paralela do frontend também foi adicionada, o que pode reduzir gargalos na fase final do build
  • No nightly, é possível ativar o recurso experimental com -Z threads=8, e o valor padrão continua sendo o modo single-thread, então não há ganho de velocidade sem configuração explícita
  • O novo frontend usa Rayon para dividir tarefas de compilação em granularidade fina, e em uma medição de exemplo o tempo do frontend caiu de 10,2 segundos para 5,9 segundos
  • Em medições com código real, o tempo de compilação caiu em até 50%, mas a variação é grande conforme as características do código e a configuração do build, e o uso de memória pode aumentar em até 35%
  • O recurso ainda está em fase experimental, e a estabilização de -Z threads junto com a execução multithread por padrão no stable está sendo planejada com meta para 2024

Um novo eixo para a paralelização da compilação no Rust

  • O frontend do compilador Rust agora pode reduzir o tempo de compilação com execução paralela
  • No compilador nightly, é possível testar o frontend multithread usando a opção -Z threads=8
  • Esse recurso ainda é experimental, e a meta de inclusão no compilador stable é 2024

Otimizações anteriores e o gargalo que restava

  • O Compiler Performance Working Group vem melhorando o desempenho do compilador Rust há vários anos
    • Nos primeiros 10 meses de 2023, o tempo médio de compilação caiu 13% segundo a ferramenta de medição de desempenho
    • O uso máximo de memória caiu 15%
    • O tamanho dos binários diminuiu 7%
  • Como o compilador já está bastante otimizado, a principal margem para ganhos maiores agora está mais próxima de ampliar o paralelismo

Limites da paralelização no Cargo e no backend

  • Ao compilar programas Rust, o Cargo executa vários processos rustc para compilar crates em paralelo
  • Se essa paralelização for desativada com a flag -j1, o tempo de compilação de programas Rust grandes aumenta bastante
  • A flag --timings do Cargo gera um gráfico em linha do tempo da compilação dos crates
  • Em um exemplo de build do ripgrep em uma máquina com 28 núcleos virtuais, aparecem 60 linhas de processo
    • A maioria é rustc, e algumas são scripts de build
    • Os 20 processos iniciais podem começar ao mesmo tempo porque não há dependências entre os crates
    • Perto do fim do build, o paralelismo diminui porque aumentam as dependências entre os crates
  • Com a pipelined compilation, dá para sobrepor em certa medida a compilação de crates dependentes, mas em programas Rust grandes a execução paralela no fim do build ainda cai bastante

O que fazem o frontend e o backend

  • O compilador Rust se divide amplamente em frontend e backend
  • O frontend faz parsing, verificação de tipos, borrow checking e outras etapas
  • O frontend anterior não conseguia usar execução paralela
  • O backend cuida da geração de código, produz código por unidade de “codegen units”, e depois o LLVM processa isso em paralelo
  • Em builds de release, o número padrão de codegen units é 16, então o perfil de exemplo mostra 16 threads do LLVM

O gargalo revelado no perfil anterior do backend

  • Em um exemplo medido com Samply ao fazer build em release do último crate do Cargo, o frontend levou 10,2 segundos
  • O backend levou 6,2 segundos, e as threads do LLVM rodaram por 5,9 segundos desse total
  • A geração paralela de código baseada em LLVM é eficaz, mas mesmo em uma máquina com 28 núcleos, as 16 threads do LLVM não rodaram todas ao mesmo tempo
  • A main thread executava em série a conversão de MIR para LLVM IR, criando um formato em escada no início das threads de codegen
  • O frontend, que funcionava de forma totalmente serial, seguia como o maior ponto de melhoria

Como o novo frontend paralelo foi implementado

  • O novo frontend usa Rayon para realizar tarefas de compilação paralelas em granularidade fina
  • Várias estruturas de dados são sincronizadas com mutexes e read-write locks, e tipos atômicos são usados onde necessário
  • Muitas tarefas do frontend foram paralelizadas, mas as mudanças ficaram concentradas em relativamente poucos pontos centrais
  • A maior parte do código do frontend não precisou ser alterada

Resultados medidos com 8 threads

  • No mesmo exemplo, com o frontend paralelo ativado e usando 8 threads, o tempo de execução do frontend caiu de 10,2 segundos para 5,9 segundos
  • O tempo do backend caiu de 6,2 segundos para 5,3 segundos, e o tempo de execução das threads do LLVM caiu de 5,9 segundos para 4,9 segundos
  • No frontend, passam a existir mais 7 threads mostradas como rustc
  • O uso das threads não é uniforme, e todas as 8 threads têm períodos de inatividade, então ainda há espaço para melhorias
  • O motivo de 8 threads do LLVM começarem ao mesmo tempo é que as 8 threads de rustc geram em paralelo o LLVM IR de 8 codegen units
  • Se o número de threads do frontend subir para 16, o formato em escada desaparece por completo, mas o tempo final de execução desse caso quase não muda

A combinação de paralelismo entre processos e dentro do processo

  • A compilação em Rust já se beneficia há muito tempo do paralelismo entre processos no Cargo e do paralelismo dentro do processo no backend
  • Agora o frontend também pode aproveitar o paralelismo dentro do processo
  • Quando vários processos rustc rodam ao mesmo tempo e cada processo cria várias threads, o jobserver protocol limita a quantidade de threads
  • Quando o paralelismo entre processos é alto, o paralelismo dentro do processo é reduzido de forma correspondente, e o total de threads não ultrapassa o número de núcleos

Como usar

  • O frontend paralelo já está incluído no compilador nightly
  • O padrão ainda é o modo single-thread, então o tempo de compilação não diminui sem alteração explícita
  • O modo multithread precisa ser ativado manualmente com a opção -Z threads
RUSTFLAGS="-Z threads=8" cargo build --release
  • Para configurar isso em um ou mais projetos com config.toml, adicione o seguinte
[build]
rustflags = ["-Z", "threads=8"]
  • O motivo de o modo single-thread ser o padrão é permitir uma adoção cuidadosa
    • O frontend paralelo contém bastante código novo
    • O modo single-thread executa a maior parte desse novo código, mas exclui a possibilidade de bugs de threading como deadlock
    • Programas paralelos são mais difíceis de escrever corretamente do que programas seriais, mesmo em Rust
    • Por isso, o frontend paralelo não será incluído em releases beta ou stable por enquanto

Impacto em desempenho e memória

  • No modo single-thread, o frontend paralelo costuma ser 0% a 2% mais lento do que o frontend serial anterior
  • No modo multithread com -Z threads=8, medições com código real mostram que o tempo de compilação pode cair em até 50%
  • O efeito no desempenho varia bastante conforme as características do código e a configuração do build
    • Builds de desenvolvimento podem ver ganhos maiores do que builds de release
    • Isso acontece porque builds de release normalmente gastam mais tempo em otimizações do backend
    • Alguns programas pequenos que já compilam rápido podem ficar mais lentos no modo multithread do que no single-thread
  • O valor recomendado é 8 threads
    • É a configuração mais testada e conhecida por trazer bons resultados
    • Valores abaixo de 8 trazem menos benefício, mas são adequados para hardware com menos de 8 núcleos
    • Valores acima de 8 têm retorno decrescente e podem até piorar o desempenho
  • O motivo de o ganho ficar na faixa de 50% ao passar de 1 para 8 threads é que o frontend representa apenas parte do tempo total de compilação, e o backend já é paralelizado
  • No modo multithread, o uso de memória pode aumentar bastante, e já foi observado aumento de até 35%

Correção e feedback

  • A confiabilidade do modo single-thread deve ser alta
  • O modo multithread ainda tem bugs conhecidos, incluindo deadlocks
  • Se a compilação travar, é possível que você tenha encontrado um desses bugs conhecidos
  • Independentemente do frontend usado, o binário gerado pelo compilador deve ser o mesmo, e qualquer diferença é tratada como bug
  • Se houver problema, verifique primeiro as issues com a label WG-compiler-parallel; se não houver uma issue correspondente, é possível abrir uma nova
  • Feedback geral pode ser enviado no wg-parallel-rustc Zulip channel, e há interesse especial nos efeitos de desempenho observados em código real

Meta para o stable em 2024

  • O trabalho de melhoria de desempenho do frontend paralelo continua em andamento
  • Como mostrado no perfil, ainda há margem para melhorar a utilização das threads do frontend
  • Os bugs restantes do modo multithread também estão sendo resolvidos
  • A meta para estabilizar a opção -Z threads e disponibilizar o frontend paralelo com multithread por padrão no stable é 2024

1 comentários

 
GN⁺ 2023-11-11
Opiniões no Hacker News
  • Sei que ainda está em estágio inicial, mas vejo que o ponto fraco do Rust é a velocidade de compilação
    Quando trabalhei em um monorepo em Rust, minha maior reclamação era a velocidade de compilação; isso aumentava os custos de CI/CD e, quando era preciso limpar o cache, também atrasava bastante o tempo de desenvolvimento
    A causa era um bug do Docker, não do Cargo, mas mesmo assim esse avanço é bem-vindo

    • Parece pouco provável que melhore de forma dramática
      Ele já foi muito otimizado, e hoje o compilador Rust tem mais paralelismo que quase todos os compiladores mainstream
      O próprio design da linguagem Rust torna a compilação mais difícil do que em linguagens como Go, que foram feitas com compilação rápida em mente
    • O rust-analyzer consome ainda mais recursos que o próprio rustc
      Não sei o quanto esse trabalho se aplicará diretamente a ele, mas seria ótimo ver grandes melhorias ali também
      É claramente algo necessário para suporte moderno em IDEs
    • Acho bem difícil concordar com isso
      Sou mantenedor de um projeto Rust open source de porte médio [1] e, localmente, sempre acho o tempo de compilação do Rust surpreendentemente rápido
      Em um MacBook Pro, builds de debug levam poucos segundos; builds de release e CI/CD são mais lentos, mas desde que comecei com Rust há 2 anos, a compilação em Rust me parece muito rápida
      Para equilibrar a comparação, no meu trabalho principal uso Java/Kotlin e Gradle, e ali sim dá para falar em tempos de compilação glaciais
      No meu projeto Rust open source, mantenho as dependências no mínimo, não uso macros além de coisas como derive[Debug, Clone] e sou muito comedido no uso de genéricos
      Seria bom se você compilasse esse projeto com cargo build e desse feedback sobre o tempo de compilação
      [1]: https://github.com/Orange-OpenSource/hurl
    • Como alguém que só fez projetos pequenos em Rust, fico curioso para saber mais ou menos quantas linhas de código havia
      E também se o projeto foi dividido em vários crates nos pontos apropriados
    • Fico curioso para saber o quão lento era na prática
      O build do monorepo levava quantos minutos?
      Pode falar com toda sinceridade. Meu compilador principal é o GHC, então dificilmente vou me surpreender
  • Talvez seja uma pergunta boba, mas o backend precisa esperar o frontend terminar a verificação de empréstimos? Se sim, por quê?
    Não estou dizendo que haja algo errado; só fico curioso se a verificação de empréstimos estabelece invariantes dos quais o backend depende, indo além de uma simples validação de consistência
    Por exemplo, há algum motivo para não fazer trabalho especulativo no backend que possa ser descartado se ocorrer um erro de verificação de empréstimos?

    • Existe um compilador Rust sem verificador de empréstimos chamado mrustc[0], então pelo menos algumas versões do Rust podem ser compiladas sem um verificador de empréstimos
      Talvez ele apenas não gere o código mais otimizado possível
      Pelo que sei, há otimizações que usam informações estabelecidas durante a verificação de empréstimos, como a notória otimização noalias. Foram necessárias várias tentativas até essa otimização ser ativada[1]
      Também não tenho certeza sobre a relação com NLL (tempos de vida não lexicais), mas, para estabelecer informações que interessem ao backend, imagino que seja necessário ao menos um verificador de empréstimos rudimentar
      Só que o mrustc também compila versões do Rust com NLL sem verificador de empréstimos, então parece estar mais no campo da otimização do que de algo obrigatório
      [0]: https://github.com/thepowersgang/mrustc
      [1]: https://stackoverflow.com/a/57259339
    • Em termos estritos, acho que não precisa necessariamente ser assim
  • Há uma forma de usar o número de núcleos de CPU, em vez de colocar um valor fixo em arquivos de configuração usados em máquinas diferentes?

    • É preciso levar em conta que isso ainda é experimental e restrito ao nightly, então não foi configurado para uso geral
      Imagino que o padrão estabilizado será o número de núcleos
      Não sei até onde esse trabalho chegou agora, mas em certo momento a ideia era coordenar as chamadas ao rustc feitas pelo Cargo via jobserver; nesse caso, seria usado o número de jobs do Cargo, cujo padrão é o número de núcleos
      O Cargo também aceita valores negativos para subtrair do número de núcleos
    • Você pode usar a variável de ambiente RUSTFLAGS, como o texto também mostra:

      $ RUSTFLAGS="-Z threads=8" cargo build --release

    • Como ele usa o protocolo jobserver e o Cargo inicializa, por padrão, com o número de núcleos, acho que, se você definir a nova flag para um valor irrealisticamente alto, como 10000, o uso ficará limitado ao número de núcleos restantes
  • Ótimo! Quando usei Rust há muito tempo, até exemplos de brinquedo compilavam bem devagar; ao voltar recentemente, vi que Rust melhorou muito e tenho usado sempre que possível, quase sem pensar no tempo de compilação
    Mas, em um projeto que ficou um pouco maior, mudanças simples começaram a levar mais de 5 segundos para compilar, e as lembranças antigas voltaram
    Chego a pensar em adiar o salvamento para o analisador não começar a rodar até eu organizar outras coisas, antes que o notebook pareça uma turbina de avião
    Para mim, é o maior ponto de dor, então qualquer avanço é muito bem-vindo

  • Bom! Ao contrário do ecossistema de crates de biblioteca, meus crates binários tendiam por padrão a ser grandes e monolíticos
    Agora estou dividindo-os em vários crates de biblioteca
    Isso significa que, além de não haver paralelização na parte final da compilação, os crates maiores são processados sequencialmente, então essa mudança é muito bem-vinda

  • Depois de ficar meio afastado de Rust por alguns anos e trabalhar em ambientes como Python ou TypeScript, usei de novo em um projeto recente, e a velocidade de compilação parecia quase instantânea
    Melhorar ainda mais é sempre bom, mas o estado atual já é bem excelente
    Hoje em dia, com o cheat code chamado ChatGPT, dá para superar quase todos aqueles problemas difíceis de Rust que, alguns anos atrás, provavelmente teriam me bloqueado; então as perspectivas para Rust parecem bem boas

    • Meus tempos de compilação em geral são bons, com uma exceção: quando crio imagens Docker para múltiplas arquiteturas no GitHub Actions
      Nesses casos, o build da imagem Docker leva de 60 a 90 minutos, e dá para sentir o quanto o projeto tem dependências no total
    • O tempo de compilação depende muito da quantidade, tamanho e complexidade das dependências
  • Há uma forma de desativar a opção de compilador paralelo sem recompilar o compilador?
    Não preciso usá-la, de todo modo já configurei codegen units como 1, e ela parece causar um ICE que eu não quero depurar
    Sei que o padrão é 1 thread, mas gostaria de desligá-la completamente

  • “O modo multithread tem bugs conhecidos, incluindo deadlocks. Se a compilação travar, é provável que você tenha encontrado um deles.”
    Então acho melhor esperar mais um pouco antes de usar -Z threads ;)