Por que um T * pode ser passado no registrador, mas um unique_ptr <T> não pode?

85

Estou assistindo a palestra de Chandler Carruth no CppCon 2019:

Não há abstrações de custo zero

nele, ele dá o exemplo de como ficou surpreso com a quantidade de sobrecarga que você incorre usando um std::unique_ptr<int>over over int*; esse segmento começa aproximadamente no ponto 17:25.

Você pode dar uma olhada nos resultados da compilação de seu exemplo de par de trechos (godbolt.org) - para testemunhar que, de fato, parece que o compilador não está disposto a passar o valor unique_ptr - que, de fato, na linha inferior é apenas um endereço - dentro de um registro, apenas na memória direta.

Um dos pontos que Carruth destaca por volta das 27:00 é que o C ++ ABI requer parâmetros de valor (alguns, mas não todos; talvez - tipos não primitivos? Tipos não trivialmente construtíveis?) Para serem passados ​​na memória em vez de dentro de um registro.

Minhas perguntas:

  1. Isso é realmente um requisito da ABI em algumas plataformas? (qual?) Ou talvez seja apenas uma pessimização em certos cenários?
  2. Por que a ABI é assim? Ou seja, se os campos de uma estrutura / classe se encaixam em registros ou mesmo em um único registro - por que não devemos ser capazes de passá-lo nesse registro?
  3. O comitê de padrões de C ++ discutiu esse ponto nos últimos anos ou nunca?

PS - Para não deixar essa pergunta sem código:

Ponteiro simples:

void bar(int* ptr) noexcept;
void baz(int* ptr) noexcept;

void foo(int* ptr) noexcept {
    if (*ptr > 42) {
        bar(ptr); 
        *ptr = 42; 
    }
    baz(ptr);
}

Ponteiro exclusivo:

using std::unique_ptr;
void bar(int* ptr) noexcept;
void baz(unique_ptr<int> ptr) noexcept;

void foo(unique_ptr<int> ptr) noexcept {
    if (*ptr > 42) { 
        bar(ptr.get());
        *ptr = 42; 
    }
    baz(std::move(ptr));
}
einpoklum
fonte
8
Eu não tenho certeza do que a exigência ABI é exatamente, mas não proíbe colocar estruturas em registos
Harold
6
Se eu tivesse que adivinhar, diria que tem a ver com funções de membros não triviais que precisam de um thisponteiro que aponte para um local válido. unique_ptrtem aqueles. Derramar o registro para esse fim anularia toda a otimização "passar em um registro".
StoryTeller - Unslander Monica
2
itanium-cxx-abi.github.io/cxx-abi/abi.html#calls . Portanto, esse comportamento é necessário. Por quê? itanium-cxx-abi.github.io/cxx-abi/cxx-closed.html , procure o problema C-7. Há alguma explicação lá, mas não é muito detalhado. Mas sim, esse comportamento não me parece lógico. Esses objetos podem ser passados ​​através da pilha normalmente. Empurrá-los para empilhar e depois passar a referência (apenas para objetos "não triviais") parece um desperdício.
geza
6
Parece que o C ++ está violando seus próprios princípios aqui, o que é bastante triste. Fiquei 140% convencido de que qualquer unique_ptr simplesmente desaparece após a compilação. Afinal, é apenas uma chamada de destruidor adiada que é conhecida em tempo de compilação.
One Man Monkey Squad
7
@MaximEgorushkin: Se você o tivesse escrito à mão, teria colocado o ponteiro em um registro e não na pilha.
einpoklum

Respostas:

49
  1. Isso é realmente um requisito da ABI ou talvez seja apenas uma pessimização em certos cenários?

Um exemplo é o System V Application Binary Interface Arquitetura AMD64 Suplemento Processor . Essa ABI é para CPUs compatíveis com x86 de 64 bits (arquitetura Linux x86_64). É seguido no Solaris, Linux, FreeBSD, macOS, Windows Subsystem para Linux:

Se um objeto C ++ tiver um construtor de cópia não trivial ou um destruidor não trivial, ele será passado por referência invisível (o objeto é substituído na lista de parâmetros por um ponteiro que possui a classe INTEGER).

Um objeto com um construtor de cópia não trivial ou um destruidor não trivial não pode ser transmitido por valor porque esses objetos devem ter endereços bem definidos. Problemas semelhantes se aplicam ao retornar um objeto de uma função.

Observe que apenas 2 registradores de uso geral podem ser usados ​​para passar 1 objeto com um construtor de cópia trivial e um destruidor trivial, ou seja, apenas valores de objetos com sizeofnão mais que 16 podem ser passados ​​em registradores. Consulte Convenções de chamada de Agner Fog para obter um tratamento detalhado das convenções de chamada, em particular §7.1 Passando e retornando objetos. Existem convenções de chamada separadas para a passagem de tipos SIMD nos registradores.

Existem ABIs diferentes para outras arquiteturas de CPU.


  1. Por que a ABI é assim? Ou seja, se os campos de uma estrutura / classe se encaixam em registros ou mesmo em um único registro - por que não devemos ser capazes de passá-lo nesse registro?

É um detalhe de implementação, mas quando uma exceção é manipulada, durante o desenrolamento da pilha, os objetos com duração automática de armazenamento sendo destruídos devem ser endereçáveis ​​em relação ao quadro da pilha de funções, porque os registros foram derrotados naquele tempo. O código de desenrolamento de pilha precisa dos endereços dos objetos para chamar seus destruidores, mas os objetos nos registradores não têm um endereço.

Pedanticamente, os destruidores operam em objetos :

Um objeto ocupa uma região de armazenamento em seu período de construção ([class.cdtor]), ao longo de sua vida útil e em seu período de destruição.

e um objeto não pode existir no C ++ se nenhum armazenamento endereçável for alocado para ele porque a identidade do objeto é o seu endereço .

Quando um endereço de um objeto com um construtor de cópia trivial mantido em registros é necessário, o compilador pode simplesmente armazenar o objeto na memória e obter o endereço. Se o construtor de cópias não é trivial, por outro lado, o compilador não pode apenas armazená-lo na memória, mas precisa chamar o construtor de cópias que faz uma referência e, portanto, requer o endereço do objeto nos registradores. A convenção de chamada provavelmente não pode depender se o construtor de cópia foi incorporado no chamado ou não.

Outra maneira de pensar sobre isso é que, para tipos trivialmente copiáveis, o compilador transfere o valor de um objeto em registradores, dos quais um objeto pode ser recuperado por armazenamentos de memória simples, se necessário. Por exemplo:

void f(long*);
void g(long a) { f(&a); }

no x86_64 com o System V ABI compila em:

g(long):                             // Argument a is in rdi.
        push    rax                  // Align stack, faster sub rsp, 8.
        mov     qword ptr [rsp], rdi // Store the value of a in rdi into the stack to create an object.
        mov     rdi, rsp             // Load the address of the object on the stack into rdi.
        call    f(long*)             // Call f with the address in rdi.
        pop     rax                  // Faster add rsp, 8.
        ret                          // The destructor of the stack object is trivial, no code to emit.

Em sua palestra instigante, Chandler Carruth menciona que uma mudança quebrada na ABI pode ser necessária (entre outras coisas) para implementar o movimento destrutivo que poderia melhorar as coisas. Na IMO, a alteração da ABI pode ser ininterrupta se as funções que usam a nova ABI optarem explicitamente por ter um novo vínculo diferente, por exemplo, declará-las em extern "C++20" {}bloco (possivelmente em um novo espaço de nome em linha para migrar APIs existentes). Para que apenas o código compilado com as novas declarações de função com a nova ligação possa usar a nova ABI.

Observe que a ABI não se aplica quando a função chamada foi incorporada. Assim como na geração do código no tempo do link, o compilador pode incorporar funções definidas em outras unidades de tradução ou usar convenções de chamada personalizadas.

Maxim Egorushkin
fonte
Comentários não são para discussão prolongada; esta conversa foi movida para o bate-papo .
Samuel Liew
8

Com ABIs comuns, o destruidor não trivial -> não pode passar nos registros

(Uma ilustração de um ponto na resposta de @ MaximEgorushkin usando o exemplo de @ harold em um comentário; corrigida conforme o comentário de @ Yakk.)

Se você compilar:

struct Foo { int bar; };
Foo test(Foo byval) { return byval; }

você obtém:

test(Foo):
        mov     eax, edi
        ret

ou seja, o Fooobjeto é passado testem um registrador ( edi) e também retornado em um registrador ( eax).

Quando o destruidor não é trivial (como o std::unique_ptrexemplo de OPs) - ABIs comuns exigem posicionamento na pilha. Isso é verdade mesmo que o destruidor não use o endereço do objeto.

Portanto, mesmo no caso extremo de um destruidor de não fazer nada, se você compilar:

struct Foo2 {
    int bar;
    ~Foo2() {  }
};

Foo2 test(Foo2 byval) { return byval; }

você obtém:

test(Foo2):
        mov     edx, DWORD PTR [rsi]
        mov     rax, rdi
        mov     DWORD PTR [rdi], edx
        ret

com carregamento e armazenamento inúteis.

einpoklum
fonte
Não estou convencido por esse argumento. O destruidor não trivial não faz nada para proibir a regra como se. Se o endereço não for observado, não há absolutamente nenhuma razão para que exista um. Portanto, um compilador em conformidade poderia colocá-lo em um registro feliz, se isso não alterar o comportamento observável (e os compiladores atuais o farão se os chamadores forem conhecidos ).
ComicSansMS
11
Infelizmente, é o contrário (concordo que parte disso já está além da razão). Para ser preciso: não estou convencido de que as razões que você forneceu necessariamente tornariam qualquer ABI concebível que permitisse passar a corrente std::unique_ptrem um registro não conforme.
ComicSansMS
3
"destruidor trivial [CITAÇÃO NECESSÁRIA]" claramente falso; se nenhum código realmente depende do endereço, então como se significa que o endereço não precisa existir na máquina real . O endereço deve existir na máquina abstrata , mas coisas na máquina abstrata que não têm impacto na máquina real são coisas como se fosse permitido eliminar.
Yakk - Adam Nevraumont
2
@einpoklum Não há nada no padrão que exista registros. A palavra-chave register apenas indica "você não pode usar o endereço". Existe apenas uma máquina abstrata no que diz respeito ao padrão. "como se" significa que qualquer implementação real da máquina precisa se comportar apenas "como se" a máquina abstrata se comporte, até um comportamento indefinido pelo padrão. Agora, existem problemas muito desafiadores em relação a ter um objeto em um registro, sobre o qual todos falamos extensivamente. Além disso, as convenções de chamada, que o padrão também não discute, têm necessidades práticas.
Yakk - Adam Nevraumont 14/10/19
11
@einpoklum Não, nessa máquina abstrata, todas as coisas têm endereços; mas os endereços são observáveis ​​apenas em determinadas circunstâncias. A registerpalavra-chave destinava-se a tornar trivial para a máquina física armazenar algo em um registro, bloqueando coisas que praticamente tornam mais difícil "não ter endereço" na máquina física.
Yakk - Adam Nevraumont
2

Isso é realmente um requisito da ABI em algumas plataformas? (qual?) Ou talvez seja apenas uma pessimização em certos cenários?

Se algo estiver visível no limite da unidade de compliação, seja definido implícita ou explicitamente, ele se tornará parte da ABI.

Por que a ABI é assim?

O problema fundamental é que os registros são salvos e restaurados o tempo todo enquanto você move para baixo e para cima na pilha de chamadas. Portanto, não é prático ter uma referência ou ponteiro para eles.

O alinhamento e as otimizações resultantes disso são bons quando isso acontece, mas um designer da ABI não pode confiar nisso. Eles precisam projetar a ABI assumindo o pior caso. Eu não acho que os programadores ficariam muito felizes com um compilador em que a ABI mudou dependendo do nível de otimização.

Um tipo trivialmente copiável pode ser passado em registradores porque a operação de cópia lógica pode ser dividida em duas partes. Os parâmetros são copiados para os registradores usados ​​para transmitir parâmetros pelo chamador e, em seguida, copiados para a variável local pelo receptor. Se a variável local possui ou não um local de memória, isso é apenas uma preocupação do chamado.

Um tipo em que um construtor de copiar ou mover deve ser usado, por outro lado, não pode ter sua operação de cópia dividida dessa maneira, portanto deve ser transmitida na memória.

O comitê de padrões de C ++ discutiu esse ponto nos últimos anos ou nunca?

Não tenho idéia se os órgãos de normas consideraram isso.

A solução óbvia para mim seria adicionar movimentos destrutivos adequados (em vez da casa intermediária atual de um "estado válido mas não especificado") ao idioma, em seguida, introduzir uma maneira de sinalizar um tipo como permitindo "movimentos destrutivos triviais" "mesmo que não permita cópias triviais.

mas essa solução exigiria quebrar a ABI do código existente para implementar nos tipos existentes, o que pode trazer um pouco de resistência (embora a ABI quebre como resultado de novas versões padrão do C ++ não sejam sem precedentes, por exemplo, as alterações std :: string em C ++ 11 resultou em uma quebra ABI ..

plugwash
fonte
Você pode elaborar como movimentos destrutivos adequados permitiriam que um unique_ptr fosse passado em um registro? Isso seria porque permitiria descartar o requisito de armazenamento endereçável?
einpoklum
Movimentos destrutivos adequados permitiriam introduzir um conceito de movimentos destrutivos triviais. Isso permitiria que a referida movimentação trivial fosse dividida pela ABI da mesma maneira que cópias triviais podem ser hoje.
Plug
Embora você também queira adicionar uma regra de que um compilador pode implementar uma passagem de parâmetro como uma movimentação ou cópia regular seguida por uma "movimentação destrutiva trivial" para garantir que sempre seja possível passar registros, independentemente de onde o parâmetro veio.
Plug
Como o tamanho do registro pode conter um ponteiro, mas uma estrutura unique_ptr? O que é sizeof (unique_ptr <T>)?
Mel Viso Martinez
@MelVisoMartinez Você pode ser confuso unique_ptre shared_ptrsemântico: shared_ptr<T>permite fornecer ao ctor 1) um ptr x ao objeto derivado U a ser excluído com o tipo estático U com a expressão delete x;(para que você não precise de um dtor virtual aqui) 2) ou até uma função de limpeza personalizada. Isso significa que o estado de tempo de execução é usado dentro do shared_ptrbloco de controle para codificar essas informações. OTOH unique_ptrnão possui essa funcionalidade e não codifica o comportamento de exclusão no estado; a única maneira de personalizar a limpeza é criar outra instância de modelo (outro tipo de classe).
Curiousguy
-1

Primeiro, precisamos voltar ao que significa passar por valor e por referência.

Para linguagens como Java e SML, a passagem por valor é direta (e não há passagem por referência), assim como a cópia de um valor variável é, pois todas as variáveis ​​são apenas escalares e possuem cópia semântica: elas são o que contam como aritmética digite C ++ ou "referências" (ponteiros com nome e sintaxe diferentes).

Em C, temos tipos escalares e definidos pelo usuário:

  • Os escalares têm um valor numérico ou abstrato (ponteiros não são números, eles têm um valor abstrato) que é copiado.
  • Os tipos agregados têm todos os seus membros possivelmente inicializados copiados:
    • para tipos de produtos (matrizes e estruturas): recursivamente, todos os membros de estruturas e elementos de matrizes são copiados (a sintaxe da função C não permite passar matrizes por valor diretamente, apenas matrizes membros de uma estrutura, mas isso é um detalhe )
    • para tipos de soma (uniões): o valor do "membro ativo" é preservado; obviamente, a cópia de membro por membro não está em ordem, pois nem todos os membros podem ser inicializados.

No C ++, os tipos definidos pelo usuário podem ter semântica de cópia definida pelo usuário, o que permite uma programação verdadeiramente "orientada a objetos" com objetos com propriedade de seus recursos e operações de "cópia profunda". Nesse caso, uma operação de cópia é realmente uma chamada para uma função que quase pode executar operações arbitrárias.

Para estruturas C compiladas como C ++, "copiar" ainda é definido como chamar a operação de cópia definida pelo usuário (construtor ou operador de atribuição), que são gerados implicitamente pelo compilador. Isso significa que a semântica de um programa de subconjunto comum de C / C ++ é diferente em C e C ++: em C, todo um tipo de agregado é copiado; em C ++, uma função de cópia gerada implicitamente é chamada para copiar cada membro; o resultado final é que, em ambos os casos, cada membro é copiado.

(Acho que há uma exceção quando uma estrutura dentro de uma união é copiada.)

Portanto, para um tipo de classe, a única maneira (fora da união de cópias) de criar uma nova instância é através de um construtor (mesmo para aqueles com construtores triviais gerados por compiladores).

Você não pode pegar o endereço de um rvalue por meio de um operador unário, &mas isso não significa que não há objeto rvalue; e um objeto, por definição, tem um endereço ; e esse endereço é mesmo representado por uma construção de sintaxe: um objeto do tipo classe só pode ser criado por um construtor e possui um thisponteiro; mas para tipos triviais, não há construtor gravado pelo usuário; portanto, não há lugar para colocar thisaté que a cópia seja construída e nomeada.

Para o tipo escalar, o valor de um objeto é o rvalor do objeto, o valor matemático puro armazenado no objeto.

Para um tipo de classe, a única noção de um valor do objeto é outra cópia do objeto, que só pode ser feita por um construtor de cópias, uma função real (embora, para tipos triviais, essa função seja tão trivial, às vezes isso pode ser criado sem chamar o construtor). Isso significa que o valor do objeto é o resultado da alteração do estado do programa global por uma execução . Não acessa matematicamente.

Portanto, passar por valor realmente não é uma coisa: é passar por chamada de construtor de cópia , o que é menos bonito. Espera-se que o construtor de cópia execute uma operação sensata de "cópia" de acordo com a semântica apropriada do tipo de objeto, respeitando seus invariantes internos (que são propriedades abstratas do usuário, não propriedades intrínsecas do C ++).

Passar pelo valor de um objeto de classe significa:

  • crie outra instância
  • faça com que a função chamada atue nessa instância.

Observe que o problema não tem nada a ver com a cópia em si ser um objeto com um endereço: todos os parâmetros de função são objetos e têm um endereço (no nível semântico do idioma).

A questão é se:

  • a cópia é um novo objeto inicializado com o valor matemático puro (valor puro verdadeiro) do objeto original, como nos escalares;
  • ou a cópia é o valor do objeto original , como nas classes.

No caso de um tipo de classe trivial, você ainda pode definir o membro da cópia de membro do original, para definir o valor puro do original devido à trivialidade das operações de cópia (construtor e atribuição de cópia). Não é assim com funções especiais arbitrárias do usuário: um valor do original deve ser uma cópia construída.

Objetos de classe devem ser construídos pelo chamador; um construtor formalmente tem um thisponteiro, mas o formalismo não é relevante aqui: todos os objetos têm formalmente um endereço, mas somente aqueles que realmente usam seu endereço de maneiras não puramente locais (ao contrário do *&i = 1;que é puramente o uso local de endereço) precisam ter um bem definido endereço.

Um objeto deve absolutamente passar por endereço se parecer que ele possui um endereço nessas duas funções compiladas separadamente:

void callee(int &i) {
  something(&i);
}

void caller() {
  int i;
  callee(i);
  something(&i);
}

Aqui, mesmo que something(address)seja uma função ou macro pura ou qualquer outra coisa (como printf("%p",arg)) que não possa armazenar o endereço ou se comunicar com outra entidade, temos o requisito de passar por endereço, porque o endereço deve ser bem definido para um objeto único intque possui um único identidade.

Não sabemos se uma função externa será "pura" em termos de endereços passados ​​para ela.

Aqui, o potencial para um uso real do endereço em um construtor ou destruidor não trivial do lado do chamador é provavelmente o motivo para seguir a rota segura e simplista e dar ao objeto uma identidade no chamador e transmitir seu endereço, conforme ele faz Certifique-se de que qualquer uso não trivial de seu endereço no construtor, após a construção e no destruidor seja consistente : thisdeve parecer o mesmo sobre a existência do objeto.

Um construtor ou destruidor não trivial, como qualquer outra função, pode usar o thisponteiro de uma maneira que exija consistência sobre seu valor, mesmo que algum objeto com material não trivial possa não:

struct file_handler { // don't use that class!
    file_handler () { this->fileno = -1; }
    file_handler (int f) { this->fileno = f; }
    file_handler (const file_handler& rhs) {
        if (this->fileno != -1)
            this->fileno = dup(rhs.fileno);
        else
            this->fileno = -1;
    }
    ~file_handler () {
        if (this->fileno != -1)
            close(this->fileno); 
    }
    file_handler &operator= (const file_handler& rhs);
};

Observe que, nesse caso, apesar do uso explícito de um ponteiro (sintaxe explícita this->), a identidade do objeto é irrelevante: o compilador pode muito bem usar copiar bit a bit o objeto para movê-lo e fazer "copiar elisão". Isso se baseia no nível de "pureza" do uso de thisfunções-membro especiais (o endereço não escapa).

Mas pureza não é um atributo disponível no nível de declaração padrão (existem extensões do compilador que adicionam descrição de pureza a declarações de funções não embutidas), portanto, você não pode definir uma ABI com base na pureza do código que pode não estar disponível (o código pode ou não pode não estar em linha e disponível para análise).

A pureza é medida como "certamente pura" ou "impura ou desconhecida". O terreno comum, ou o limite superior da semântica (na verdade, máximo), ou LCM (Mínimo Múltiplo Comum) é "desconhecido". Assim, a ABI resolve o desconhecido.

Resumo:

  • Algumas construções requerem que o compilador defina a identidade do objeto.
  • O ABI é definido em termos de classes de programas e não em casos específicos que podem ser otimizados.

Possível trabalho futuro:

A anotação de pureza é útil o suficiente para ser generalizada e padronizada?

curiousguy
fonte
11
Seu primeiro exemplo parece enganador. Eu acho que você está apenas fazendo uma observação em geral, mas no começo eu pensei que você estivesse fazendo uma analogia com o código da pergunta. Mas void foo(unique_ptr<int> ptr)pega o objeto de classe por valor . Esse objeto tem um membro ponteiro, mas estamos falando sobre o próprio objeto de classe sendo passado por referência. (Como não é trivialmente copiável, é necessário que seu construtor / destruidor seja consistente this.) Esse é o argumento real e não está conectado ao primeiro exemplo de passagem por referência explicitamente ; nesse caso, o ponteiro é passado em um registro.
Peter Cordes
@ PeterCordes " você estava fazendo uma analogia com o código da pergunta. " Eu fiz exatamente isso. " o objeto de classe por valor " Sim, eu provavelmente deveria explicar que, em geral, não existe o "valor" de um objeto de classe, portanto, por valor para um tipo não matemático, não é "por valor". " Esse objeto tem um membro ponteiro " A natureza semelhante a ptr de um "ptr inteligente" é irrelevante; e também o membro ptr do "smart ptr". Um ptr é apenas um escalar como um int: escrevi um exemplo de "fileno inteligente" que ilustra que "propriedade" não tem nada a ver com "carregar um ptr".
precisa
11
O valor de um objeto de classe é sua representação de objeto. Pois unique_ptr<T*>, esse é o mesmo tamanho e layout que T*cabe em um registro. Objetos de classe copiáveis ​​de maneira trivial podem ser passados por valor em registradores no x86-64 System V, como na maioria das convenções de chamada. Isso faz uma cópia do unique_ptrobjeto, diferente do intexemplo em que o chamado &i é o endereço do chamador iporque você passou por referência no nível C ++ , não apenas como um detalhe de implementação do asm.
Peter Cordes
11
Err, correção no meu último comentário. Não é apenas fazer uma cópia do unique_ptrobjeto; está usando, std::moveentão é seguro copiá-lo, porque isso não resultará em duas cópias do mesmo unique_ptr. Mas para um tipo trivialmente copiável, sim, ele copia todo o objeto agregado. Se esse membro é único, as convenções de boas chamadas tratam o mesmo como um escalar desse tipo.
Peter Cordes
11
Parece melhor. Notas: Para estruturas C compiladas como C ++ - Esta não é uma maneira útil de introduzir a diferença entre C ++. Em C ++ struct{}é uma estrutura C ++. Talvez você deva dizer "estruturas simples" ou "ao contrário de C". Porque sim, há uma diferença. Se você usar atomic_intcomo um membro struct, C o copiará de maneira não atômica, erro C ++ no construtor de cópia excluído. Eu esqueço o que o C ++ faz em estruturas com volatilemembros. C permitirá que você struct tmp = volatile_struct;copie a coisa toda (útil para um SeqLock); C ++ não.
Peter Cordes