2 pontos por GN⁺ 2024-04-20 | 1 comentários | Compartilhar no WhatsApp

Este texto explica em detalhes como melhorar a Calling Convention da linguagem Rust.

Problemas da Calling Convention atual do Rust

  • Atualmente, o Rust não tem uma convenção de chamada (Calling Convention) claramente definida.
  • Na prática, ele usa a convenção de chamada C padrão do LLVM.
  • Hoje, o Rust tenta gerar assinaturas de função em LLVM que o Clang provavelmente geraria, de forma conservadora.
    • Para compatibilidade com depuradores
    • Para evitar bugs do LLVM
  • Mas isso é conservador demais e gera código ruim até para funções simples.
fn extract(arr: [i32; 3]) -> i32 { arr[1] }
  • O código acima deveria ser passado por registrador, mas é passado por ponteiro.
  • O Rust é ainda mais conservador que a ABI C. Se for especificado como extern "C", ele é passado por registrador.

Proposta de nova Calling Convention

  • Manter a convenção de chamada existente para funções extern "Rust".
  • Adicionar a flag -Zcallconv para configurar a convenção de chamada de funções extern "Rust".
    • -Zcallconv=legacy é a forma atual.
    • -Zcallconv=fast é a nova forma a ser projetada.
  • Por que manter a convenção de chamada existente?
    • Para facilitar a depuração, ela não organiza na ordem da ABI C.
    • Alguns alvos, como WASM, podem não ter suporte.
    • Em builds de depuração, isso pode não fazer diferença.
  • Cuidados relacionados a ponteiros de função e blocos extern "Rust" {}
    • Como é uma flag por crate, não pode ser aplicada a ponteiros de função.
    • Chamadas por ponteiro de função são lentas e raras, então usam -Zcallconv=legacy.
    • Se necessário, gerar um shim para converter a convenção de chamada.
    • Em casos de chamada direta como extern "Rust" { fn my_func() -> i32; }
      • Só é possível chamar símbolos sem mangling.
      • Funções com #[no_mangle] usam a convenção de chamada existente.

Como aproveitar o LLVM

  • Idealmente, seria ótimo poder especificar diretamente a convenção de chamada no LLVM, mas isso é difícil na prática.
  • É possível contornar com o seguinte procedimento.
    • Verificar, para o alvo dado, a quantidade máxima de valores que podem ser passados por registrador.
    • Decidir como retornar o valor. Se couber em registradores, retornar diretamente; se for grande, passar por referência.
    • Selecionar, entre os argumentos passados por valor, quais devem ser passados por referência.
      • Os que forem maiores que o espaço disponível para passagem por registrador
      • No x86, algo em torno de 176 bytes
    • Decidir quais argumentos serão passados por registrador para aproveitar ao máximo esse espaço.
      • É um problema NP-hard, então é preciso usar heurísticas.
      • O restante é passado pela pilha.
    • Gerar a assinatura da função em LLVM IR.
      • Os argumentos passados por registrador são representados como tipos não agregados, como i64, ptr, double, <2 x i64> etc.
      • Os argumentos passados pela pilha seguem a entrada de registradores.
    • Gerar o prólogo da função.
      • Decodificar os argumentos no nível Rust a partir da entrada de registradores e gerar os mesmos valores %ssa de quando se usa -Zcallconv=legacy.
      • O corpo da função pode gerar o mesmo código independentemente da convenção de chamada.
      • Código de decodificação desnecessário é removido por DCE.
    • Gerar o bloco de retorno da função.
      • Incluir instruções phi para o tipo de retorno, como em -Zcallconv=legacy.
      • Codificar no formato de saída necessário e retornar com ret.
      • Em vez de retornar diretamente, é preciso desviar para esse bloco.
    • Se houver funções não polimórficas e não inline que possam ser usadas por ponteiro de função
      • Quando forem expostas para fora do crate ou passadas como ponteiro de função
      • Gerar um shim usando -Zcallconv=legacy e fazer Tail Call para a implementação real
      • Isso é necessário para preservar a equivalência de ponteiros de função

Como verificar os limites de passagem por registrador no LLVM

  • Um programa LLVM para verificar a quantidade máxima de passagem por registrador permitida pelo LLVM
  • No x86, são possíveis 6 inteiros e 8 vetores SSE como entrada, e 3 inteiros e 4 vetores SSE como saída.
  • No aarch64, são 8 inteiros e 8 vetores tanto para entrada quanto para saída.
  • Ao ultrapassar isso, os valores são passados pela pilha.

Tratamento de structs e enums no Rust

  • Assume-se que o rustc já tratou isso como agregados básicos e unions.
  • Tratamento de valor de retorno
    • O importante não é o tamanho da struct, mas o tamanho real dos dados excluindo padding.
    • [(u64, u32); 2] tem 32 bytes, mas excluindo 8 bytes de padding restam 24 bytes.
    • Definir o tamanho efetivo (Effective Size) do tipo
      • A quantidade de bits definidos, excluindo padding
      • [(u64, u32); 2] tem 192 bits
      • bool tem 1 bit
    • Se o tamanho efetivo for menor que o espaço de registradores de saída, retornar por valor.
    • No x86, 3 inteiros + 4 SSE = 88 bytes = 704 bits
  • Tratamento de registradores para argumentos
    • Problema de mochila, portanto NP-hard
    • Heurística simples
      • Se o tamanho efetivo for maior que o espaço total de registradores de entrada, passar por referência.
      • Substituir enums por pares discriminante-union.
      • Como unions podem tocar bits não inicializados, passá-las como array de u8 ou como uma única variante não vazia.
      • Achatar em elementos mais básicos, como ponteiros, inteiros, floats, booleanos etc.
      • Ordenar em ordem crescente pelo tamanho efetivo.
      • Alocar em registradores o maior prefixo possível e mandar o restante para a pilha.
      • Se parte de uma entrada que vai para a pilha for maior que um pequeno múltiplo do tamanho de ponteiro, passá-la pelo ponteiro da pilha.
      • O restante é passado diretamente pela pilha na ordem anterior à ordenação.
      • O que for passado por registrador é alocado em ordem decrescente de tamanho.
      • Booleanos são empacotados em bits de 64 em 64.

Opinião do GN+

  • Pessoalmente, acho a convenção de chamada atual do Rust muito decepcionante. Ela poderia entregar desempenho bem melhor que C++, mas ainda não consegue.
  • É uma abordagem que a linguagem Go já implementou há bastante tempo.
  • Motivos pelos quais o Rust não consegue aplicar isso
    • A geração de código ABI é complexa, e o LLVM não ajuda muito.
    • Não há muitas pessoas na equipe do compilador que conheçam bem LLVM.
    • Há preocupação com o tempo de compilação, mas como isso seria usado só em builds otimizadas, não é um grande problema.
  • O autor não tem tempo para corrigir isso diretamente, mas está disposto a ajudar a equipe do compilador Rust com base em sua experiência em LLVM.
  • Ou então simplesmente migrar para extern "C" ou extern "fastcall" também pode ser uma alternativa.

1 comentários

 
GN⁺ 2024-04-20
Comentários do Hacker News

Resumo:

  • Ao criar uma convenção de chamada (Calling Convention) otimizada, é importante medir o desempenho diretamente. Um código que parece estranho pode, na prática, ser o mais rápido.
  • As CPUs atuais otimizam os rastros de instruções gerados por compiladores C, então passar dados pela stack com frequência, como um compilador C faz, pode ajudar.
  • Como o inlining costuma funcionar bem e as chamadas se tornam limites raros, pode-se permitir um pouco de irregularidade nesses limites para simplificar outras partes.
  • As structs do Rust precisam fornecer referências para os campos, então podem acabar maiores que em C. Uma struct com 8 campos Option<u8> tem 16 bytes em Rust e 9 bytes em C.
  • Em Rust, é possível implementar manualmente algo equivalente ao C, mas isso não pode ser mapeado com &Option<T> ou &mut Option<T>.
  • Rust ainda não tem uma convenção de chamada para semântica em nível de Rust. A Apple teve motivação para construir isso, mas Rust não conta com esse suporte.
  • A interoperabilidade entre Go e Rust atualmente pode ser obtida usando Zig como intermediário.
  • O compilador Rust atual faz inlining agressivo e otimiza bastante, então é questionável se vale a pena resolver esse problema.
  • Para depuração, é possível evitar preocupações usando flags no Cargo.toml. Ordenar os campos por tamanho é uma otimização fácil, e isso pode ser desativado com repr.