A convenção de chamada Rust que deveríamos ter
(mcyoung.xyz)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
-Zcallconvpara configurar a convenção de chamada de funçõesextern "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
%ssade 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.
- Decodificar os argumentos no nível Rust a partir da entrada de registradores e gerar os mesmos valores
- 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.
- Incluir instruções phi para o tipo de retorno, como em
- 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=legacye 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 bitsbooltem 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
u8ou 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"ouextern "fastcall"também pode ser uma alternativa.
1 comentários
Comentários do Hacker News
Resumo:
Option<u8>tem 16 bytes em Rust e 9 bytes em C.&Option<T>ou&mut Option<T>.repr.