2 pontos por GN⁺ 2024-04-20 | 1 comentários | Compartilhar no WhatsApp
  • A convenção de chamada atual extern "Rust" do Rust depende do caminho da convenção de chamada C do LLVM e é conservadora no uso de registradores ao passar valores complexos, perdendo oportunidades de gerar código melhor
  • A ideia central é separar, com a flag por crate -Zcallconv, o modo atual legacy e um novo modo orientado a registradores, fast, usando uma ABI mais agressiva em builds otimizadas
  • Mesmo sem adicionar uma nova convenção de chamada diretamente ao LLVM, é possível controlar a disposição dos argumentos com assinaturas fixas de função no LLVM e valores poison para deixar argumentos de registrador não usados vazios sem custo
  • Tipos Rust como structs, enums, unions, bool e Result podem ser passados de forma mais densa com base em tamanho efetivo sem padding, achatamento, bit packing e heurísticas de divisão entre pilha e registradores
  • Se o corpo da função, informações do borrow checker e dados de perfil forem refletidos na decisão da ABI, otimizações ainda mais fortes se tornam possíveis, mas a complexidade da geração de código de ABI no rustc e a falta de especialização em LLVM continuam sendo barreiras reais

As otimizações de convenção de chamada que o Rust perde hoje

  • A convenção de chamada (calling convention) é parte da ABI que define como argumentos e valores de retorno são transmitidos, quais registradores são usados e como prólogo/epílogo e unwinding são tratados
  • O Rust define sua própria convenção de chamada não especificada, mas na prática ela é reduzida para a convenção de chamada C embutida do LLVM e depende da geração de código de prólogo/epílogo do LLVM
  • O rustc age de forma conservadora para tentar gerar assinaturas de função no LLVM como as que o Clang produziria
    • Isso pode reduzir a chance de quebrar depuradores
    • Também pode diminuir a chance de acionar bugs no LLVM por meio de caminhos de geração de ABI pouco usados pelo Clang
  • Em sistemas baseados em ELF, o DWARF não fixa a ABI C do Linux, então, no escopo do texto, a possibilidade de depuração não é tratada como o problema principal
  • Em um exemplo simples, fn extract(arr: [i32; 3]) -> i32, o array de 12 bytes é passado por ponteiro em vez de registradores
    • Se for usado extern "C", o mesmo [i32; 3] é passado empacotado em rdi e rsi
    • É um caso em que o caminho padrão do Rust é ainda mais conservador do que a ABI C do Linux

-Zcallconv: separando legacy e fast

  • A convenção de chamada atual de extern "Rust" é mantida, mas a flag de compilação por crate -Zcallconv escolhe qual convenção usar
    • -Zcallconv=legacy: modo atual
    • -Zcallconv=fast: novo modo orientado a registradores
    • -O poderia até definir automaticamente -Zcallconv=fast
  • A convenção fast não organiza os argumentos na ordem da ABI C, então pode parecer confusa para quem espera a ordem idiomática de registradores no x86
  • Em alvos como WASM, onde não há conceito de registradores e spilling, -Zcallconv=fast talvez não seja suportado
  • Em builds de debug sem otimização, fast pode gerar código pior, então sua ativação pode não ser apropriada
  • Ponteiros de função e blocos extern "Rust" {} exigem restrições separadas
    • A flag vale no nível do crate, mas é difícil expressar em ponteiros de função qual versão de extern "Rust" está sendo usada
    • Chamadas por ponteiro de função podem ser tratadas como caminhos lentos e raros, forçando -Zcallconv=legacy
    • Se necessário, pode-se gerar um shim que converte a convenção de chamada
    • Por causa do caminho que permite chamar símbolos sem mangling, símbolos #[no_mangle] também podem acabar usando a convenção legacy

Controlando o LLVM de forma indireta

  • Idealmente, seria possível especificar diretamente ao LLVM algo como “este argumento vai neste registrador, este valor de retorno vai naquele”, mas adicionar uma convenção de chamada ao LLVM exigiria bastante código em C++
  • Em vez disso, é possível obter um efeito próximo de uma convenção própria com o seguinte processo
    • Determinar, por target triple, o número máximo de valores que podem ser passados em registradores
    • Decidir se o valor de retorno cabe em registradores de saída ou se deve ser retornado por referência com um argumento extra ptr marcado com sret
    • Rebaixar argumentos by-value muito grandes para by-reference
    • Escolher quais argumentos irão para registradores de forma a maximizar a ocupação do espaço de registradores
    • Colocar os demais argumentos na pilha
    • Montar a assinatura da função em LLVM IR com argumentos não agregados como i64, ptr, double e <2 x i64>
    • No prólogo da função, decodificar as entradas de registrador em argumentos no nível Rust
    • No bloco de saída da função, codificar o retorno no formato de saída necessário e então fazer ret
    • Para funções não polimórficas, não inline e cujo endereço pode ser tomado, gerar um shim legacy para preservar a identidade do ponteiro de função
  • O problema de decidir quais valores colocar em registradores é parecido com o problema da mochila (knapsack problem) e é NP-hard, então a implementação prática exige heurísticas
  • Essas informações podem ser colocadas no rmeta para evitar recálculo em vez de serem computadas tarde demais
  • Como o Rust já quebra ABI entre releases, a exigência de impedir o link de código gerado por compiladores Rust diferentes já está alinhada com a situação atual

Os limites do LLVM para passagem por registradores

  • O LLVM tenta “explodir” ao máximo argumentos by-value agregados em registradores ao passá-los para funções
  • No x86, os dados de entrada que o LLVM consegue passar em registradores são aproximadamente estes
    • 6 inteiros
    • 8 vetores SSE
    • para retorno, metade disso: 3 inteiros e 4 vetores
  • Em aarch64-unknown-linux, tanto entrada quanto saída podem usar 8 inteiros e 8 vetores
  • É possível projetar todas as funções -Zcallconv=fast no x86 para terem o mesmo número de argumentos passados por registrador
    • 6 argumentos para registradores inteiros
    • 8 argumentos vetoriais de xmm0 até xmm7
    • ao passar ponteiros reais, o i64 correspondente vira ptr
    • ao passar double, ele substitui uma posição <2 x i64>
  • Mesmo que a maioria das funções não passe 176 bytes, é possível evitar custo extra enviando poison do LLVM nos argumentos não usados
    • O LLVM pode tratar poison como o valor que for mais conveniente no momento
    • Se poison for passado como argumento de registrador, isso pode ser tratado como “o valor que já estava naquele registrador”, sem necessidade de tocá-lo
    • No exemplo, o código que passa um ponteiro para load_rcx() em rcx e poison nos outros 13 registradores não gera instrução alguma após otimização
  • Essa técnica dá controle quase total sobre a passagem de argumentos, mas a situação ideal de usar os mesmos registradores para entrada e saída varia por arquitetura
    • ARM e RISC-V ficam mais próximos de uma estrutura em que entrada e saída usam os mesmos registradores
    • x86 não é assim, mas ainda é possível reduzir movimentos desnecessários de registrador assumindo uma ordem diferente de alocação

Ajustando melhor os tipos Rust aos registradores

  • Ao lidar com structs e unions Rust, assume-se que o rustc já tratou os tipos do usuário como aggregates e unions básicos, e então se decide que partes vão para registradores
  • Para valores de retorno, o tamanho efetivo sem padding é mais importante do que o tamanho total da struct
    • [(u64, u32); 2] ocupa 32 bytes no total, mas 8 bytes são padding
    • Ao achatar para (u64, u32, u64, u32) e ordenar por tamanho como (u64, u64, u32, u32), o total cai para 24 bytes
    • Isso cabe nos 3 registradores inteiros de retorno do x86
  • O tamanho efetivo é definido pelo número de bits não undef
    • [(u64, u32); 2] tem 192 bits
    • bool tem 1 bit
    • char tecnicamente tem 21 bits, mas por simplicidade é tratado como um alias de u32
  • Structs com muitos bool podem retornar vários bool com bit packing em um único registrador
  • No lado dos argumentos isso é mais difícil, e algumas heurísticas possíveis são
    • Rebaixar para by-reference argumentos cujo tamanho efetivo seja maior que todo o espaço de entrada by-register
    • No x86, o espaço total de entrada é de 176 bytes, ou 1408 bits
    • Transformar enums em um par de discriminant e union
      • Option<i32> pode ser visto internamente como (union { i32, () }, i1)
      • Option<Option<i32>> pode ser visto como (union { i32, (), () }, i2)
    • Como unions podem tocar bits não inicializados arbitrariamente, em geral elas são passadas como um array de u8
    • Uma union com apenas uma variant não vazia pode ser substituída por essa própria variant
    • O argumento transformado é achatado em primitivas como ponteiro, inteiro, float e bool
    • Campos maiores que um registrador de argumento pequeno, como u128 e f64, podem ser divididos
    • A lista de primitivas é ordenada por tamanho efetivo, e escolhe-se o maior prefixo que caiba nos registradores
    • O restante vai para a pilha
    • Se a parte que iria para a pilha for maior que um pequeno múltiplo do tamanho de ponteiro, ela pode ser rebaixada para pointer-on-the-stack para reduzir tráfego de memória
    • Valores passados por registrador são alocados dos maiores para os menores, e bool pode ser compactado até 64 por registrador

Exemplo de função Rust complexa e os limites atuais do rustc

  • No exemplo de do_thing, que recebe Option<usize>, &dyn Context, &str, [char; 6] e uma struct Options, após achatamento e ordenação todos os argumentos brutos do LLVM cabem em registradores
  • Os tipos LLVM dos argumentos brutos no exemplo ficam nesta forma
    • gprs: i64, ptr, ptr, ptr, i64, i32, i32
    • xmm0: i32, i32, i32, i32
    • xmm1: i32, i1, i1, i1, i1
  • O prólogo da função extrai as primitivas e recompõe os valores no nível Rust
    • Option<usize> vira { i64, i1 }
    • o trait object vira { ptr, ptr }
    • &str vira { ptr, i64 }
    • [char; 6] vira [6 x i32]
    • Options vira { i32, i1, i1, i1 }
  • Se as instruções que materializam os valores dos argumentos receberem metadados !dbg, o gdb pode mostrar os valores dos argumentos com resultados melhores
  • Hoje o rustc passa 8 parâmetros do tamanho de ponteiro para o LLVM para essa mesma função e, como resultado, usa todos os 6 registradores inteiros e envia 2 valores pela pilha

Espaço para otimização em retornos e Result

  • Esse desenho não cobre todas as otimizações possíveis de convenção de chamada
  • Em alguns casos, registradores extras como os AVX do x86 também poderiam ser usados
  • Também pode ser considerado passar uma struct dividida entre registradores e pilha
  • O retorno de Result tem espaço próprio para otimização
    • Quando ele atravessa várias camadas de função via ?, pode haver muitos movimentos redundantes de registrador
    • Se o Result for grande demais para caber em registradores, cada chamada com ? na pilha de chamadas pode precisar carregar da memória e testar o bit de ok
    • Como alternativa, o erro pode ser mantido em um ponteiro de out-parameter, enquanto o payload da variant ok e o bit is-ok são retornados como Option<T>
    • Os detalhes de ? quando envolve chamada de Into são complicados, mas implementáveis

ABI dependente de otimização

  • Diferentemente de C, em -Zcallconv=fast o Rust pode olhar o corpo da função ao montar a ABI vista pelo chamador
  • O crate pode anunciar, por função, a ABI exata do ponto de vista de passagem por registradores
  • A otimização mais simples é eliminar da ABI argumentos que não são usados
    • Se a função não usa certo parâmetro, nenhum registrador é gasto com ele
  • Se um argumento &T não for mantido, não virar raw pointer, T for pequeno e T: Freeze, o próprio valor apontado pode ser passado por valor em vez da referência
  • APIs como HashMap::get() são candidatas
    • Se a key for um tipo como i32, hoje é preciso fazer spill do inteiro para a pilha e passar seu ponteiro
    • Esse tráfego de memória pode ser evitado
  • Uma ABI guiada por perfil é ainda mais agressiva
    • Argumentos mais hot podem ter prioridade na ordem de alocação de registradores
    • Mesmo ao receber uma struct grande por referência, 3 campos i64 hot podem ser carregados previamente pelo chamador e passados tanto por ponteiro quanto por registradores
    • O callee teria de fazer esses loads de qualquer forma, então não haveria custo adicional
    • Um perfil por instrumentação pode até justificar a duplicação de funções que diferem apenas na ABI

Por que isso ainda não foi feito

  • O Rust tem menos restrições de ABI que o C++, então consegue gerar código melhor, e a ideia se aproxima do que a Go register ABI já usa na prática
  • O primeiro obstáculo é a complexidade da geração de código de ABI
    • O LLVM quase não oferece knobs úteis de controle
    • Mesmo dentro do rustc, essa não é uma área amigável
    • Uma implementação errada pode prejudicar bastante a usabilidade
  • Outro obstáculo é a falta de especialização
    • Entre os contribuidores do rustc, são poucos os que entendem bem a semântica do LLVM e as características da geração de código a ponto de produzir bom código sem fazer o LLVM travar
  • O tempo de compilação também pode ser um peso
    • Quanto mais complexa a assinatura da função, mais código de prólogo/epílogo o LLVM precisa processar
    • Ainda assim, como -Zcallconv é pensado para uso apenas com otimização ativada, isso não é visto como desvantagem decisiva
  • O código de ABI do Rust é uma área com baixo bus factor, e conhecimento de LLVM pode ser aplicado diretamente para ajudar o time do compilador Rust a gerar código mais otimizado

1 comentários

 
GN⁺ 2024-04-20
Comentários do Hacker News
  • Ao otimizar uma calling convention, o principal não é imaginar mentalmente qual formato parece melhor, e sim medir o desempenho
    Código bom é código rápido, não código que parece rápido
    Às vezes, o que o autor chama de código ruim acaba sendo o mais rápido por motivos nada intuitivos, e isso só dá para saber medindo em benchmarks grandes
    Um dos motivos de uma calling convention que parece ruim funcionar bem é que ela economiza registradores de argumentos, deixando o alocador de registradores um pouco mais à vontade
    Além disso, as CPUs de hoje são otimizadas para o fluxo de instruções gerado por compiladores C; então, se você gerar um código no estilo de compiladores C que fazem passagem pela pilha com uma frequência surpreendente, especialmente o MSVC, pode acabar acertando o ponto ideal da CPU
    Como o inlining está tão eficiente, chamadas em caminhos quentes acabam virando limites raros, e mesmo que esse limite fique um pouco bagunçado, tudo bem se isso simplificar as outras partes
    Isso não quer dizer que a mudança seja ruim, mas discutir apenas olhando para código que parece estranho, sem medições, é estranho
    Eu trabalhei profissionalmente com otimização de calling convention no JavaScriptCore, e era surpreendentemente comum ver código de passagem pela pilha que parecia pior vencer em código real de grande porte

    • Concordo fortemente que código que parece rápido nem sempre é realmente rápido
      Mas também acho que resultados de medição de desempenho não deveriam ser o único critério
      Na expressão de que as CPUs estão otimizadas “hoje em dia”, a palavra importante é “hoje”, porque as CPUs continuam mudando, então a calling convention precisa ser um projeto de longo prazo
      Por isso, infelizmente, é melhor não se afastar demais da forma como o C++ faz isso. É provável que as otimizações dos processadores no futuro também mirem esse lado
      Ao mesmo tempo, é bom considerar princípios gerais que não mudam facilmente, como economizar registradores de argumentos, para tornar a calling convention robusta e preparada para o futuro
      O Rust parece ter ficado conservador demais nos últimos anos em relação ao orçamento de estranheza (https://steveklabnik.com/writing/the-language-strangeness-bu...), então é até um pouco curioso eu dizer isso. No fim, sem ser diferente, não dá para melhorar
    • Se passar por registrador é mais rápido também depende do corpo da função
      Se a função já começa pegando o endereço dos parâmetros e passando para uma função desconhecida, no fim das contas isso vai ter de ir para a pilha de qualquer jeito
      Seria interessante ver uma otimização de calling convention baseada no corpo da função. No caso de funções estáticas em C, parece seguro desde que o endereço não seja obtido
    • Essa experiência não se transfere completamente
      Um JIT já entra nessa questão com muita informação sobre a CPU real em execução antes mesmo de gerar uma única linha de assembly, então tem vantagem nisso
      Em código puramente compilado de forma estática, não dá para saber em tempo de execução qual é o conjunto de recursos da arquitetura, então você acaba encontrando com frequência barreiras de inlining justamente no código que mais gostaria de otimizar
    • Desempenho pode incluir não só velocidade de execução, mas também tamanho do binário
      Hoje o Rust parece fraco nesse aspecto em plataformas pequenas, e a calling convention pode ajudar nisso em relação ao retorno de Result
    • O texto original trata principalmente de x86, e a Intel passou décadas fazendo um trabalho de engenharia impressionante para que código x86 feio rode rápido no próprio silício que vende
      Ainda assim, fico curioso se as vantagens empíricas da passagem pela pilha continuam valendo em CPUs ARMV8 ou RISC-V, que têm muitos registradores
  • É um rascunho razoável, mas falta a distinção entre caller-saved e callee-saved, e há o erro comum de alocar parte dos registradores de entrada para a saída
    Também é otimista esperar que o depurador vá entender uma convenção de chamada diferente da de C. Não importa o que o DWARF consiga codificar, na prática há uma grande chance de isso falhar miseravelmente
    Mudar a ABI conforme a configuração de otimização interage muito mal com a compilação separada
    Reorganizar argumentos como se fosse um empacotamento denso até funcionaria, mas aumentaria bastante a complexidade do compilador, e não sei se isso vale mais a pena do que uma alocação simples da esquerda para a direita no primeiro encaixe possível. Também ficaria mais difícil para o desenvolvedor prever para onde cada argumento vai
    A direção geral de usar convenções de chamada diferentes para funções cujo endereço escapa e para as que não escapa faz sentido. Também funciona bem separar prólogos que façam o casamento de impedância
    Rust deveria estar disposto a ter uma convenção de chamada diferente da de C, mas não sei se isso precisa ser uma única convenção rígida usada por todas as funções. Colocar isso no sistema de tipos parece natural, e deixar o desenvolvedor controlar a convenção de chamada elimina uma das vantagens de desempenho do assembly

    • Fico curioso sobre por que usar alguns registradores de entrada também como registradores de saída seria um problema tão grande
      Do ponto de vista do chamador, de qualquer forma os registradores de saída precisam estar livres entre duas chamadas de função, e isso também é bem comum em convenções de chamada de system calls
      Imagino que a intenção seja facilitar para o callee preparar os valores de saída sem mexer nos valores de entrada. Se for isso, até entendo querer colocar os registradores de saída no fim da ordem dos registradores de entrada para evitar sobreposição, mas não vejo muito motivo para proibir completamente qualquer sobreposição
    • Se o desenvolvedor puder controlar a convenção de chamada, isso também impede otimizações que trocam a convenção dos argumentos de funções intermediárias numa cadeia como Function A chamando Function B, Function C e Function D, para reduzir overhead
      Fico em dúvida sobre qual seria a semântica que preservaria esse tipo de otimização e ao mesmo tempo permitiria esse controle, ou se isso no fim não passa de uma ilusão
      Na prática, assembly quase nunca é alvo da maioria das otimizações de compilador, então há uma desvantagem de desempenho. Muitas vezes ele também não recebe otimizações do tipo “analisar o comportamento, concluir que é totalmente redundante e eliminar tudo de uma vez”, e já não estamos mais nos anos 1990
      Ainda assim, se for um caso em que esse tipo de otimização nem pode ser considerado, então eu diria que o único ponto em que assembly inline realmente perde com certeza é em otimização guiada por perfil. Isso porque o desenvolvedor da aplicação conhece perfeitamente o comportamento do código, e o desenvolvedor do compilador não
      O overhead de chamada pode ser eliminado usando mais assembly até cobrir os limites quentes relevantes
    • O DWARF atualmente não codifica convenções de chamada personalizadas de forma alguma
    • O empacotamento denso pode até acabar deixando tudo mais lento e, no caso de bool, em especial, pode criar uma cadeia de dependência
      No x64, para bool, não parece haver jeito muito melhor do que primeiro colocá-los em registradores, fazer shift e aplicar OR no resultado
      A abordagem simples cria uma cadeia de dependência de comprimento 64 e pode gerar uma penalidade de 64 ciclos, embora com cuidado talvez dê para reduzir isso para 6 ciclos e, realisticamente, algo em torno de 12 ciclos
      Mas também existe a questão de onde viriam 64 bool. Não há tantos registradores assim, então no fim seria preciso reler da pilha
      Se a ABI do Rust já empacota bool dentro de structs de forma tão densa assim, então isso de qualquer maneira já teria de ser feito, mas não sei ao certo
      E depois o chamador ainda precisa desempacotar tudo de novo
      Parece mais fácil ensinar o compilador a derramar os valores para o espaço de resultado na pilha, e isso provavelmente também teria desempenho melhor
    • A maioria dos processadores modernos consegue encaminhar facilmente leituras logo após escritas, e há várias técnicas para rastrear o estado da pilha
      Nesse caso, fico em dúvida sobre o quanto colocar valores em registradores realmente ajuda
  • A convenção de chamada de C é meio ruim
    É verdade que não dá para mudar a convenção de chamada de C, mas isso não a torna menos frustrante
    Todos os registradores caller-saved disponíveis deveriam ser usados para argumentos e valores de retorno, mas na ABI SysV tradicional só se usa um registrador para retorno, às vezes dois
    Se você retorna struct Point3D { long x, y, z }, daria para colocar Point3D em rax, rdi, rsi, mas em vez disso ele é derramado para a pilha
    Outros sistemas têm outros truques. Se lembro bem, no SBCL uma função define o carry flag ao terminar quando retorna múltiplos valores. Por exemplo, talvez fosse bom usar o carry flag para indicar se um Result contém um erro

    • “Ruim” é uma palavra forte, mas no caso de valores de retorno está certo
      A convenção de chamada de C basicamente suporta o que C suporta, isto é, retornar um único argumento. Nem retorno de struct ela faz direito
      Em C, isso fica mais para um “não era óbvio?”, e no lado de C++ vira algo como “é só fazer inline”
      Por outro lado, spills para memória realmente acontecem. Por exemplo, o espaço generoso de registradores e as janelas do SPARC deixavam muitos registradores sem uso em funções simples, e quando o anel de registradores sofria spill isso levava a um uso grande de pilha que destruía o cache
      No x86, mesmo com muitos mov para rearranjar dados para “onde eles precisam estar”, no fim isso muitas vezes era mais rápido
      Quando se olha só para o código do callee, dá vontade de dizer “esse argumento aqui e esse valor de retorno ali certamente seriam mais rápidos”, mas não dá para saber nada sobre o chamador
      Não dá para garantir que a preparação dos argumentos vá passar direto, nem que o valor de retorno vá ser consumido de forma quente. Por exemplo, se struct Point { x: i32, y: i32, z: i32 } for usado como argumento/retorno e o chamador estiver num loop fazendo algo como mystruct.deepinside.point[i] = func(mystruct.deepinside.point[i]), colocar e tirar isso de registradores pode virar overhead ou até impedir vetorização
      O callee não tem como saber disso, exceto quando o compilador consegue ver os dois lados e fazer inline
      O fruto mais baixo na árvore em relação a chamadas parece ser remover a suposição, embutida em quase toda ABI de C, de que funções retornam um único valor primitivo. Para o resto, seria preciso muito benchmarking e muitas estatísticas de geração de código
  • Há outro detalhe frustrante em Rust que faz structs crescerem mais do que se gostaria
    Pense em uma struct Foo com 8 campos Option que são None ou Some(u8); em C, isso pode ser representado em 9 bytes no total, com 8 bool de 1 bit e 8 uint8_t
    Em Rust, isso vira 16 bytes, com 1 byte de discriminante e uint8_t repetido 8 vezes
    A razão é que a struct precisa ser capaz de fornecer empréstimos de seus campos. Se você tem &Foo, o compilador precisa ser capaz de criar &Foo::some_field, isto é, &Option, e esse &Option precisa ter a mesma forma que todos os outros &Option do programa
    Portanto, o Option interno precisa ter o mesmo layout que outros Option do programa: seu bit discriminante arredondado para um byte, mais o u8. A struct paga esse custo mesmo que &Foo::some_field nunca seja criado
    Fica pior se você pensar em Option de tipos maiores. Numa struct com 8 campos Option, cada discriminante é arredondado para 2 bytes, totalizando 32 bytes, e um quarto disso — quase metade se contar os bits não usados dos discriminantes — é desperdiçado como padding intermediário. O equivalente em C precisa de 18 bytes
    Ao usar Option, a struct em Rust pode ficar com 128 bytes, enquanto a struct em C pode ter 72 bytes
    Claro, é possível implementar a mesma representação de C colocando um u8 para discriminantes empacotados e 8 MaybeUninit, e escrevendo manualmente funções que mapeiem &Foo para Option<&T> e &mut Foo para Option<&mut T>. Só não dá para passar por &Option ou &mut Option
    https://play.rust-lang.org/?version=stable&mode=debug&editio...

    • A versão em C também precisa ser implementada manualmente, então não é tão estranho que em Rust também seja preciso fazer isso
      Você está basicamente descrevendo um tipo definido pelo usuário que contém 8 Option, e, quando se começa a se preocupar com desempenho, é preciso tocar manualmente o tratamento interno desses Option
    • A versão equivalente em C também precisa ser implementada manualmente
      O fato de Rust oferecer recursos convenientes que você pode escolher quando combinam com o objetivo não chega a ser uma desvantagem
      O caso de uso descrito é relativamente raro e, se for um gargalo real de desempenho, gastar um pouco mais de tempo implementando isso em Rust não é um grande problema
      Em uso geral, os benefícios do tipo Option<_> são enormes, então é difícil considerar isso um “detalhe frustrante” de Rust
  • Mudando um pouco de assunto: há um trecho dizendo que, se o endereço de uma função não polimórfica e não inline puder ser obtido como ponteiro de função, então se criaria um shim usando -Zcallconv=legacy e a implementação real faria tail call imediata; entendo a intenção de preservar a equivalência de ponteiros de função
    Mas, se o shim legado fizer tail call para uma função com calling convention de Rust, isso não impediria de corrigir a diferença no valor de retorno da convenção?

    • Sim. As pessoas tendem a esquecer a metade de retorno da convenção de chamada, então isso parece um erro de digitação compreensível
  • Em um tema um pouco diferente, fiquei curioso se hoje existe interoperação entre Go e Rust
    Lembro de ter visto no passado um caso que conseguiu isso colocando Zig no meio, mas não consigo encontrar. Tenho código legado em Rust e gostaria de migrar aos poucos para Go

    • Sim. Dá para chamar funções Rust usando FFI extern "C" com CGO
      O pessoal da busca de código do GitHub apresentou como faz isso na RustConf 2023(https://www.youtube.com/watch?v=KYdlqhb267c) e depois ouvi dizer que lugares como a 1Password também estão fazendo algo parecido
      Não é divertido ficar transportando tipos através da fronteira de interoperabilidade com C, mas é possível e também permite reutilizar código
    • Para chamar Rust a partir de Go, basta declarar as funções Rust como extern "C" e chamá-las de Go como se fossem C
      No sentido contrário, não sei muito bem
    • Misturar memória gerenciada e não gerenciada geralmente não é uma ideia muito sábia
      O código gerenciado precisa poder possuir memória que será liberada ou movida, e o código não gerenciado precisa raciocinar sobre quando a memória será liberada ou movida
      Coisas como cgo permitem misturar chamadas FFI da parte gerenciada do Go para memória não gerenciada, mas isso tem um custo
      Esse problema sempre aparece em implementações nas quais as linguagens que se chamam mutuamente não compartilham o coletor de lixo
      Misturar código gerenciado/não gerenciado é uma ideia antiga, mas ainda é um tema de pesquisa ativo
      A menos que o runtime embutido tenha sido projetado para isso, chamar código gerenciado a partir de código não gerenciado quase sempre é uma má ideia, e normalmente entra uma camada de serialização no meio
    • Como preciso usar bastante Rust e Swift, acabei chegando num arranjo em que trocamos arrays de bytes de protobuf serializado por meio de chamadas de função bem convencionais
      Se esse for o seu trabalho principal, talvez você ache ruim, mas eu cansei de voltar ao código a cada poucas semanas e não lembrar mais como tudo funcionava
    • Como exemplo bem amaldiçoado, recentemente chamei código Go a partir de Rust, com C no meio
      Passei um closure Rust com estado como callback para código Go e o injetei numa função da biblioteca padrão de Go, incluindo até o desenrolar de panic dentro do closure Rust
      https://github.com/Voultapher/sort-research-rs/commit/df6c91...
  • Fiquei um tempão no Inspecionar elemento tentando descobrir como o título da seção foi deixado inclinado, mas pelas ferramentas do Safari eu não estava conseguindo ver. Como isso foi feito, afinal?

    • O estilo está no elemento .post-title: transform: skewY(-2deg) translate(-1rem, -0.4rem);
    • Relacionado a isso, achei que o minimapa usava a função CSS element() (https://developer.mozilla.org/en-US/docs/Web/CSS/element), mas na verdade era uma cópia do corpo do texto reduzida a um tamanho minúsculo
    • Em h1, h2, h3, h4, h5, h6 são aplicados transform:skewY(-2deg) translate(-1rem,0rem);, transform-origin:top;, font-style:italic;, text-decoration-line:underline;, text-decoration-color:goldenrod;, text-underline-offset:4%;, text-decoration-thickness:.25ex
  • Em contraste, existe o texto de 2019 “How Swift Achieved Dynamic Linking Where Rust Couldn't”
    https://faultlore.com/blah/swift-abi/
    É uma pena que Rust ainda não tenha uma calling convention para semântica em nível de Rust, mas ao mesmo tempo esse texto mostra que a quantidade de trabalho necessária para chegar lá é enorme
    A Apple tinha uma motivação muito forte para tornar Swift uma linguagem de sistema prática da qual aplicações pudessem depender, mas Rust não tem esse tipo de patrocínio
    Discussão no HN: https://news.ycombinator.com/item?id=21488415

    • Também é justo apontar que a abordagem do Swift tem custo em tempo de execução
      Seria bom se Rust tivesse mais opções de suporte para esse compromisso, e isso não precisaria ficar limitado a coisas como https://github.com/rust-lang/rfcs/pull/3470
  • Se o compilador atual de Rust fizer inline agressivamente e depois otimizar, fico em dúvida se isso realmente vale o esforço
    Se a função chamada for pequena, ela será colocada inline; se for grande, então bastante tempo será gasto dentro dela, então o overhead da chamada será pequeno

    • Funções de runtime, por exemplo dyn Trait, não podem ser colocadas inline, então esse tipo de mudança ajuda
      Se der para baratear as chamadas, talvez não seja necessário fazer inline de forma tão agressiva, o que também pode ajudar no tamanho do código e no tempo de compilação
    • Provavelmente vale a pena
      Funções complexas demais para inline provavelmente farão alguns acessos à memória, e esses acessos têm boa chance de serem o gargalo
      Passar dados pela stack aperta ainda mais esse gargalo, porque aumenta a pressão de cache e a quantidade de loads/stores
      Se Rust conseguir passar argumentos de forma ideal em uma fração significativa das chamadas de função, isso não só evitaria alguns ciclos de acesso à L1, como também poderia fazer a CPU atingir mais rápido o gargalo de memória essencial
      Talvez o ganho seja de alguns poucos por cento, mas agora estou bebendo vinho e não vou fazer as contas
  • Alguém pode explicar qual é a mnemônica “Diana’s silk dress cost $89” que aparece nos materiais de referência sobre x86?