Sobre [[trivial_abi]] em Clang

Por fim, escrevi um post sobre [[trivial_abi]]!

Esse é um novo recurso proprietário no tronco Clang, novo em fevereiro de 2018. Esta é uma extensão de fornecedor da linguagem C ++, não é C ++ padrão, não é suportada pelo tronco GCC e não há propostas ativas do WG21 para incluí-lo no padrão C ++, tanto quanto eu sei.



Eu não participei da implementação desse recurso. Apenas olhei para os remendos na lista de e-mails do commit e aplaudi silenciosamente para mim mesmo. Mas esse é um recurso tão interessante que acho que todo mundo deveria saber.

Portanto, a primeira coisa que começaremos: esse não é um atributo padrão, e o tronco Clang não suporta a ortografia padrão do atributo [[trivial_abi]] para ele. Em vez disso, você deve escrevê-lo no estilo antigo, como mostrado abaixo:

__attribute__((trivial_abi)) __attribute__((__trivial_abi__)) [[clang::trivial_abi]] 

E, como esse é um atributo, o compilador é muito exigente sobre onde você o cola e passivamente agressivamente silencioso se você o cola no lugar errado (pois os atributos não reconhecidos são simplesmente ignorados sem mensagens). Isso não é um bug, é um recurso. A sintaxe correta é esta:

 #define TRIVIAL_ABI __attribute__((trivial_abi)) class TRIVIAL_ABI Widget { // ... }; 


Que problema isso resolve?



Lembra da minha postagem em 17/04/2018, onde eu mostrei duas versões da classe?

Nota perev: Como o post de 17/04/2018 tem um pequeno volume, não o publiquei separadamente, mas o inseri aqui embaixo do spoiler.
post de 17/04/2018

Desvantagens da falta de chamada do trivial destruidor


Consulte a lista de correspondência da proposta padrão do C ++. Qual das duas funções, foo ou bar, terá o melhor código gerado pelo compilador?

 struct Integer { int value; ~Integer() {} // deliberately non-trivial }; void foo(std::vector<int>& v) { v.back() *= 0xDEADBEEF; v.pop_back(); } void bar(std::vector<Integer>& v) { v.back().value *= 0xDEADBEEF; v.pop_back(); } 


Compilando com GCC e libstdc ++. Adivinha certo?

 foo: movq 8(%rdi), %rax imull $-559038737, -4(%rax), %edx subq $4, %rax movl %edx, (%rax) movq %rax, 8(%rdi) ret bar: subq $4, 8(%rdi) ret 


Aqui está o que acontece aqui: O GCC é inteligente o suficiente para entender que, quando um destruidor para uma área de memória é iniciado, sua vida útil termina e todas as entradas anteriores nessa área de memória ficam "mortas". Mas o GCC também é inteligente o suficiente para entender que um destruidor trivial (como o pseudo-destruidor ~ int ()) não faz nada e não produz efeitos.

Portanto, a função bar chama pop_back, que executa ~ Integer (), o que torna vec.back () morto, e o GCC remove completamente a multiplicação por 0xDEADBEEF.

Por outro lado, foo chama pop_back, que lança o pseudo-destruidor ~ int () (ele pode ignorar completamente a chamada, mas não o faz), o GCC vê que está vazio e esquece. Portanto, o GCC não vê que vec.back () está morto e não remove a multiplicação por 0xDEADBEEF.

Isso acontece para um destruidor trivial, mas não para um pseudo-destruidor como ~ int (). Substitua nosso ~ Integer () {} por ~ Integer () = padrão; e veja como a instrução original apareceu novamente!

 struct Foo { int value; ~Foo() = default; // trivial }; struct Bar { int value; ~Bar() {} // deliberately non-trivial }; 

Nesse post, é fornecido o código no qual o compilador gerou o código para Foo pior que para Bar. Vale a pena discutir por que isso foi inesperado. Os programadores esperam intuitivamente que o código "trivial" seja melhor que o código "não trivial". Este é o caso na maioria das situações. Em particular, este é o caso quando fazemos uma chamada de função ou retornamos:

 template<class T> T incr(T obj) { obj.value += 1; return obj; } 

incr compila para o seguinte código:

 leal 1(%rdi), %eax retq 

(leal é o comando x86 que significa “adicionar”.) Vemos que nosso objetivo de 4 bytes é passado para incr no registro% edi e adicionamos 1 ao seu valor e o devolvemos a% eax. Quatro bytes na entrada, quatro bytes na saída, fácil e simples.

Agora vejamos incr (o caso de um destruidor não trivial).

 movl (%rsi), %eax addl $1, %eax movl %eax, (%rsi) movl %eax, (%rdi) movq %rdi, %rax retq 

Aqui, obj não é passado no registro, apesar de aqui os mesmos 4 bytes com a mesma semântica. Aqui obj é passado e retornado ao endereço. Aqui, o chamador reserva algum espaço para o valor de retorno e nos passa um ponteiro para esse espaço em rdi, e o chamador nos fornece um ponteiro para o valor de retorno obj no próximo registro de argumentos% rsi. Extraímos o valor de (% rsi), adicionamos 1, salvamos de volta a (% rsi) para atualizar o valor de obj e depois copiamos (trivialmente) 4 bytes de obj no slot para o valor de retorno apontado por% rdi. Por fim, copiamos o ponteiro original transmitido pelo chamador de% rdi para% rax, pois o documento ABI x86-64 (p. 22) nos diz para fazer isso.

A razão pela qual Bar é tão diferente de Foo é porque Bar tem um destruidor não trivial e o ABI x86-64 (p. 19) especifica especificamente:

Se um objeto C ++ tiver um construtor de cópia não trivial ou um destruidor não trivial, ele é passado por um link invisível (o objeto é substituído por um ponteiro na lista de parâmetros)

Um documento posterior do Itanium C ++ ABI define o seguinte:
Se o tipo de parâmetro não for trivial para a finalidade da chamada, o chamador deve alocar um local temporário e passar um link para esse local temporário:
[...]
Um tipo é considerado não trivial para a finalidade da chamada se:

Ele possui um construtor de cópia não trivial, um construtor em movimento, um destruidor ou todos os seus construtores em movimento e cópia são excluídos.

Então, isso explica tudo: a barra tem uma geração de código pior porque é passada por um link invisível. É transmitido através de um link invisível desde que ocorreu uma combinação infeliz de duas circunstâncias independentes:
  • Documento da ABI diz que objetos com destruidor não trivial são passados ​​por links invisíveis
  • Bar tem um destruidor não trivial.

Este é um silogismo clássico: o primeiro ponto é a premissa principal, o segundo é privado. Como resultado, a barra é transmitida através de um link invisível.

Que alguém nos dê um silogismo:
  • Todas as pessoas são mortais
  • Sócrates é um homem.
  • Consequentemente, Sócrates é mortal.


Se queremos refutar a conclusão “Sócrates é mortal”, devemos refutar uma das premissas: refutar a coisa principal (talvez algumas pessoas não sejam mortais) ou refutar o privado (talvez Sócrates não seja uma pessoa).

Para que Bar seja passado em um registro (como Foo), devemos refutar uma das duas premissas. O caminho C ++ padrão é dar a Bar um destruidor trivial, destruindo a premissa particular. Mas existe outro caminho!

Como [[trivial_abi]] resolve o problema


O novo atributo Clang destrói a premissa principal. Clang estende o documento ABI da seguinte maneira:
Se o tipo de parâmetro não for trivial para a finalidade da chamada, o chamador deve alocar um local temporário e passar um link para esse local temporário:
[...]
Um tipo é considerado não trivial para a finalidade da chamada se estiver marcado como [[trivial_abi]] e:
Ele possui um construtor de cópia não trivial, um construtor em movimento, um destruidor ou todos os seus construtores em movimento e cópia são excluídos.

Mesmo que uma classe com um construtor ou destruidor móvel não trivial possa ser considerada trivial para o propósito da chamada, se estiver marcada como [[trivial_abi]].

Então agora, usando Clang, podemos escrever assim:

 #define TRIVIAL_ABI __attribute__((trivial_abi)) struct TRIVIAL_ABI Baz { int value; ~Baz() {} // deliberately non-trivial }; 

compile incr <Baz> e obtenha o mesmo código que incr <Foo>!

Aviso nº 1: [[trivial_abi]] às vezes não faz nada


Eu espero que possamos criar invólucros "triviais para fins de chamada" sobre tipos de biblioteca padrão, como este:

 template<class T, class D> struct TRIVIAL_ABI trivial_unique_ptr : std::unique_ptr<T, D> { using std::unique_ptr<T, D>::unique_ptr; }; 

Infelizmente, isso não funciona. Se a sua classe tiver alguma classe base ou campos não estáticos "não triviais para a finalidade da chamada", a extensão Clang na forma em que foi gravada agora tornará sua classe "irreversivelmente não trivial" e o atributo não terá efeito. (Nenhuma mensagem de diagnóstico é emitida. Isso significa que você pode usar [[trivial_abi]] no modelo de classe como um atributo opcional, e a classe será "condicionalmente trivial", o que às vezes é útil. A desvantagem, é claro, é que você pode marque a classe como trivial e descubra que o compilador a corrigiu silenciosamente.)

O atributo será ignorado sem mensagens se sua classe tiver uma classe base virtual ou funções virtuais. Nesses casos, pode não caber nos registros e não sei o que você deseja obter, passando por valor, mas você provavelmente sabe.

Então, até onde eu sei, a única maneira de usar o TRIVIAL_ABI para "tipos utilitários padrão", como opcional <T>, unique_ptr <T> e shared_ptr <T>, é
  • implementá-los você mesmo do zero e aplicar o atributo ou
  • entre na sua cópia local da libc ++ e insira o atributo lá com suas mãos

(no mundo do código aberto, os dois métodos são essencialmente os mesmos)

Aviso # 2: responsabilidade do destruidor


No exemplo com Foo / Bar, a classe possui um destruidor vazio. Deixe nossa classe realmente ter um destruidor não trivial.

 struct Up1 { int value; Up1(Up1&& u) : value(u.value) { u.value = 0; } ~Up1() { puts("destroyed"); } }; 

Isso deve ser familiar para você, isso é unique_ptr <int>, simplificado até o limite, com a impressão de uma mensagem quando excluída.

Sem TRIVIAL_ABI, o incr <Up1> se parece com o incr <Bar>:

 movl (%rsi), %eax addl $1, %eax movl %eax, (%rdi) movl $0, (%rsi) movq %rdi, %rax retq 


Com TRIVIAL_ABI, incr parece maior e mais assustador !

 pushq %rbx leal 1(%rdi), %ebx movl $.L.str, %edi callq puts movl %ebx, %eax popq %rbx retq 


Na convenção de chamada tradicional, tipos com um destruidor não trivial são sempre transmitidos por um link invisível, o que significa que o lado receptor (neste caso, incr) sempre aceita um ponteiro para um objeto de parâmetro sem possuir esse objeto. O objeto pertence ao chamador, o que faz o trabalho de elision funcionar!

Quando um tipo com [[trivial_abi]] é passado em registradores, essencialmente fazemos uma cópia do objeto de parâmetro.

Como o x86-64 possui apenas um registro para retornar (aplausos), a função chamada não tem como retornar o objeto no final. A função chamada deve se apropriar do objeto que passamos para ele! Isso significa que a função chamada deve chamar o destruidor do objeto de parâmetro quando terminar.

No exemplo anterior, Foo / Bar / Baz, o destruidor é chamado, mas estava vazio e não percebemos. Agora, no incr <Up2>, vemos o código adicional gerado pelo destruidor no lado da função chamada.

Pode-se supor que esse código adicional possa ser gerado em alguns casos de usuário. Mas, pelo contrário, a chamada do destruidor não aparece em lugar algum! É chamado incr, porque não é chamado na função de chamada. E, em geral, preço e benefícios serão equilibrados.

Aviso nº 3: ordem do destruidor


O destruidor de um parâmetro com uma ABI trivial será chamado pela função chamada, e não a chamada (aviso nº 2). Richard Smith aponta que isso significa que ele não será chamado na ordem em que os destruidores dos outros parâmetros estão localizados.

 struct TRIVIAL_ABI alpha { alpha() { puts("alpha constructed"); } ~alpha() { puts("alpha destroyed"); } }; struct beta { beta() { puts("beta constructed"); } ~beta() { puts("beta destroyed"); } }; void foo(alpha, beta) {} int main() { foo(alpha{}, beta{}); } 

Este código imprime:

 alpha constructed beta constructed alpha destroyed beta destroyed 

quando TRIVIAL_ABI é definido como [[clang :: trivial_abi]], ele imprime:

 alpha constructed beta constructed beta destroyed alpha destroyed 

Relacionamento com um objeto "trivialmente relocável" / "mover-relocalizado"


Sem relação ... hein?

Como você pode ver, não há requisitos para a classe [[trivial_abi]] ter semântica específica para o construtor em movimento, destruidor ou construtor padrão. Qualquer classe em particular provavelmente será trivialmente relocável, simplesmente porque a maioria das classes é trivialmente relocável.

Podemos simplesmente criar a classe offset_ptr para que não seja trivialmente relocável:

 template<class T> class TRIVIAL_ABI offset_ptr { intptr_t value_; public: offset_ptr(T *p) : value_((const char*)p - (const char*)this) {} offset_ptr(const offset_ptr& rhs) : value_((const char*)rhs.get() - (const char*)this) {} T *get() const { return (T *)((const char *)this + value_); } offset_ptr& operator=(const offset_ptr& rhs) { value_ = ((const char*)rhs.get() - (const char*)this); return *this; } offset_ptr& operator+=(int diff) { value_ += (diff * sizeof (T)); return *this; } }; int main() { offset_ptr<int> top = &a[4]; top = incr(top); assert(top.get() == &a[5]); } 

Aqui está o código completo.
Quando TRIVIAL_ABI é definido, o tronco Clang passa neste teste em -O0 e -O1, mas em -O2 (ou seja, assim que tenta alinhar chamadas para trivial_offset_ptr :: operator + = e o construtor de cópia), ele falha na afirmação.

Então, mais um aviso. Se o seu tipo faz algo tão louco com esse ponteiro, você provavelmente não vai querer passá-lo nos registros.

Bug 37319 , de fato, um pedido de documentação. Nesse caso, verifica-se que não há como fazer o código funcionar da maneira que o programador deseja. Dizemos que o valor de value_ deve depender do valor deste ponteiro, mas na fronteira entre a chamada e as funções chamadas, o objeto está nos registradores e o ponteiro para ele não existe! Portanto, a função de chamada grava na memória e passa o ponteiro this novamente, e como a função chamada deve calcular o valor correto para gravá-lo no valor_? Talvez seja melhor perguntar como isso funciona em -O0? Este código não deve funcionar.

Portanto, se você quiser usar [[trivial_abi]], evite funções-membro (não apenas especiais, mas qualquer) que dependem muito do endereço do próprio objeto (com algum significado indefinido da palavra "essencial").

Intuitivamente, quando uma classe é marcada como [[trivial_abi]], sempre que você espera copiar, você pode obter cópia mais memcpy. Da mesma forma, quando você espera uma mudança, pode obter a mudança mais o memcpy.

Quando um tipo é "trivialmente relocável" (como definido por mim no C ++ agora ), a qualquer momento que você espera copiar e destruir, é possível obter o memcpy. E da mesma forma, quando você espera deslocamento e destruição, pode realmente obter memcpy. De fato, chamadas para funções especiais serão perdidas se falarmos sobre "realocação trivial", mas quando a classe tiver o atributo [[trivial_abi]] de Clang, as chamadas não serão perdidas. Você acabou de receber (por assim dizer) o memcpy, além das chamadas que esperava. Esse (tipo de) memcpy é o preço que você paga por uma convenção de registro de chamadas mais rápida.

Links para leitura adicional:


Tópico cfe-dev de Akira Hatanaka de novembro de 2017
Documentação oficial do Clang
A unidade testa trivial_abi
Bug 37319: trivial_offset_ptr não pode funcionar

Source: https://habr.com/ru/post/pt468759/


All Articles