É uma prática ruim escrever código que depende de otimizações do compilador?

95

Eu tenho aprendido algum C ++ e, muitas vezes, tenho que retornar objetos grandes de funções que são criadas dentro da função. Eu sei que há a passagem por referência, retornar um ponteiro e retornar um tipo de referência soluções, mas eu também li que compiladores C ++ (e o padrão C ++) permitem a otimização do valor de retorno, que evita a cópia desses objetos grandes através da memória, assim economizando tempo e memória de tudo isso.

Agora, sinto que a sintaxe é muito mais clara quando o objeto é explicitamente retornado por valor, e o compilador geralmente empregará o RVO e tornará o processo mais eficiente. É uma má prática confiar nessa otimização? Isso torna o código mais claro e mais legível para o usuário, o que é extremamente importante, mas devo ter cuidado ao supor que o compilador vai pegar a oportunidade da RVO?

Isso é uma micro-otimização, ou algo que eu deveria ter em mente ao criar meu código?

    
por Matt 12.10.2017 / 14:39
fonte

14 respostas

128

Empregue o princípio do menos espanto .

É você e só você que vai usar este código, e você tem certeza que o mesmo em 3 anos não vai se surpreender com o que você faz?

Então vá em frente.

Em todos os outros casos, use o modo padrão; caso contrário, você e seus colegas terão dificuldades para encontrar bugs.

Por exemplo, meu colega estava reclamando do meu código causando erros. Acontece que ele desativou a avaliação booleana de curto-circuito nas configurações do compilador. Eu quase dei um tapa nele.

    
por 12.10.2017 / 14:58
fonte
78

Para este caso em particular, definitivamente apenas retorne por valor.

  • RVO e NRVO são otimizações conhecidas e robustas que devem ser feitas por qualquer compilador decente, mesmo no modo C ++ 03.

  • A semântica de movimento garante que os objetos sejam removidos das funções se (N) o RVO não ocorrer. Isso só é útil se seu objeto usa dados dinâmicos internamente (como std::vector ), mas isso deve ser realmente o caso se for que grande - estourar a pilha é um risco com grandes objetos automáticos.

  • C ++ 17 impõe RVO. Então não se preocupe, não vai desaparecer em você e só vai terminar de se estabelecer completamente assim que os compiladores estiverem atualizados.

E, no final, forçar uma alocação dinâmica adicional para retornar um ponteiro ou forçar seu tipo de resultado a ser construtível por padrão apenas para que você possa passá-lo como um parâmetro de saída são soluções feias e não idiomáticas para um problema provavelmente nunca terá.

Basta escrever código que faça sentido e agradecer aos criadores do compilador pela otimização correta do código que faz sentido.

    
por 12.10.2017 / 17:31
fonte
60

Now, I feel that the syntax is much clearer when the object is explicitly returned by value, and the compiler will generally employ the RVO and make the process more efficient. Is it bad practice to rely on this optimization? It makes the code clearer and more readable for the user, which is extremely important, but should I be wary of assuming the compiler will catch the RVO opportunity?

Isso não é uma micro-otimização pouco conhecida, bonitinha que você lê em algum blog pequeno e pouco trafegado, e então você começa a se sentir inteligente e superior sobre o uso.

Após o C ++ 11, o RVO é o caminho padrão para escrever este código de código. É comum, esperado, ensinado, mencionado em palestras, mencionado em blogs, mencionado no padrão, será reportado como um bug do compilador se não for implementado. Em C ++ 17, a linguagem vai um passo além e exige a elisão da cópia em certos cenários.

Você deve confiar totalmente nessa otimização.

Além disso, o retorno por valor apenas leva a um código muito mais fácil de ler e gerenciar do que o código que é retornado por referência. Semântica de valor é uma coisa poderosa, que em si poderia levar a mais oportunidades de otimização.

    
por 12.10.2017 / 19:05
fonte
16

A exatidão do código que você escreve deve nunca depender de uma otimização. Ele deve produzir o resultado correto quando executado na "máquina virtual" C ++ que eles usam na especificação.

No entanto, o que você fala é mais uma questão de eficiência. Seu código funciona melhor se for otimizado com um compilador de otimização de RVO. Tudo bem, por todas as razões apontadas nas outras respostas.

No entanto, se você exigir essa otimização (como, por exemplo, se o construtor de cópias causar falhas no código), agora você está no capricho do compilador.

Acho que o melhor exemplo disso em minha prática é a otimização da chamada de ponta:

   int sillyAdd(int a, int b)
   {
      if (b == 0)
          return a;
      return sillyAdd(a + 1, b - 1);
   }

É um exemplo tolo, mas mostra uma chamada final, em que uma função é chamada recursivamente no final de uma função. A máquina virtual C ++ mostrará que este código funciona corretamente, embora eu possa causar uma pequena confusão quanto a por que eu me incomodei em escrever tal rotina de adição em primeiro lugar. No entanto, nas implementações práticas do C ++, temos uma pilha e um espaço limitado. Se feito de forma pedante, esta função teria que empurrar pelo menos b + 1 de quadros de pilha para a pilha, como faz a sua adição. Se eu quiser calcular sillyAdd(5, 7) , isso não é grande coisa. Se eu quiser calcular sillyAdd(0, 1000000000) , posso estar com problemas para causar um StackOverflow (e não o bom tipo ).

No entanto, podemos ver que, quando chegamos à última linha de retorno, terminamos com tudo no quadro de pilha atual. Nós realmente não precisamos manter isso por perto. A otimização da chamada de cauda permite "reutilizar" o quadro de pilha existente para a próxima função. Dessa forma, precisamos apenas de 1 quadro de pilha, em vez de b+1 . (Ainda temos que fazer todas essas adições e subtrações bobas, mas elas não ocupam mais espaço). Na verdade, a otimização transforma o código em:

   int sillyAdd(int a, int b)
   {
      begin:
      if (b == 0)
          return a;
      // return sillyAdd(a + 1, b - 1);
      a = a + 1;
      b = b - 1;
      goto begin;  
   }

Em alguns idiomas, a otimização da chamada de retorno é explicitamente exigida pela especificação. C ++ é não um desses. Não posso confiar em compiladores C ++ para reconhecer essa oportunidade de otimização de chamada final, a menos que eu vá caso a caso. Com a minha versão do Visual Studio, a versão de lançamento faz a otimização da chamada final, mas a versão de depuração não (por design).

Assim, seria ruim para mim depender de poder calcular sillyAdd(0, 1000000000) .

    
por 12.10.2017 / 21:29
fonte
8

Na prática os programas C ++ estão esperando algumas otimizações do compilador.

Observe os cabeçalhos padrão de suas implementações padrão de contêineres . Com o GCC , você pode solicitar o formulário pré-processado ( g++ -C -E ) e a representação interna GIMPLE ( g++ -fdump-tree-gimple ou Gimple SSA com -fdump-tree-ssa ) da maioria dos arquivos de origem (tecnicamente unidades de tradução) usando contêineres. Você ficará surpreso com a quantidade de otimização realizada (com g++ -O2 ). Assim, os implementadores de contêineres dependem das otimizações (e na maioria das vezes, o implementador de uma biblioteca padrão C ++ sabe qual otimização aconteceria e grava a implementação do contêiner com essas em mente; às vezes ele também escreveria a passagem de otimização no compilador para lidar com os recursos exigidos pela biblioteca C ++ padrão).

Na prática, são as otimizações do compilador que tornam o C ++ e seus contêineres padrão suficientemente eficientes. Então você pode confiar neles.

E, da mesma forma, para o caso de RVO mencionado em sua pergunta.

O padrão C ++ foi co-projetado (especialmente experimentando otimizações suficientemente boas ao propor novos recursos) para funcionar bem com as otimizações possíveis.

Por exemplo, considere o programa abaixo:

#include <algorithm>
#include <vector>

extern "C" bool all_positive(const std::vector<int>& v) {
  return std::all_of(v.begin(), v.end(), [](int x){return x >0;});
}

compile com g++ -O3 -fverbose-asm -S . Você descobrirá que a função gerada não executa nenhuma instrução CALL da máquina. Portanto, a maioria das etapas do C ++ (construção de um fechamento lambda, seu aplicativo repetido, obtenção dos begin e end iteradores, etc ...) foram otimizadas. O código da máquina contém apenas um loop (que não aparece explicitamente no código-fonte). Sem essas otimizações, o C ++ 11 não será bem-sucedido.

adendos

(adicionado em 31 de dezembro em 2017)

Veja CppCon 2017: Matt Godbolt “O que meu compilador fez por mim ultimamente? Desprendendo a tampa do compilador ” fale.

    
por 13.10.2017 / 12:12
fonte
3

Sempre que você usa um compilador, o entendimento é que ele produzirá códigos de máquina ou de byte para você. Não garante nada sobre como é o código gerado, exceto que implementará o código fonte de acordo com a especificação da linguagem. Note que esta garantia é a mesma, independentemente do nível de otimização usado, e assim, em geral, não há razão para considerar uma saída como mais 'direita' que a outra.

Além disso, nesses casos, como o RVO, onde é especificado na linguagem, parece ser inútil sair do seu caminho para evitar usá-lo, especialmente se ele simplificar o código-fonte.

É feito um grande esforço para que os compiladores produzam resultados eficientes e, claramente, a intenção é que esses recursos sejam usados.

Pode haver razões para usar código não otimizado (para depuração, por exemplo), mas o caso mencionado nesta pergunta não parece ser um (e se o seu código falhar apenas quando otimizado, e não é uma consequência de algum peculiaridade do dispositivo em que você está executando, então há um bug em algum lugar, e é improvável que ele esteja no compilador.)

    
por 12.10.2017 / 22:58
fonte
2

Eu acho que os outros cobriram bem o ângulo específico sobre o C ++ e o RVO. Aqui está uma resposta mais geral:

Quando se trata de correção, você não deve confiar nas otimizações do compilador ou no comportamento específico do compilador em geral. Felizmente, você não parece estar fazendo isso.

Quando se trata de desempenho, você tem que confiar no comportamento específico do compilador em geral, e nas otimizações do compilador em particular. Um compilador compatível com o padrão está livre para compilar seu código da maneira que desejar, contanto que o código compilado se comporte de acordo com a especificação da linguagem. E eu não estou ciente de nenhuma especificação para uma linguagem mainstream que especifique quão rápido cada operação deve ser.

    
por 16.10.2017 / 02:38
fonte
1

As otimizações do compilador só devem afetar o desempenho, não os resultados. Baseando-se em otimizações de compilador para atender a requisitos não funcionais não é apenas razoável, é freqüentemente a razão pela qual um compilador é escolhido em detrimento de outro.

Sinalizadores que determinam como determinadas operações são executadas (condições de índice ou estouro, por exemplo), são frequentemente incluídos nas otimizações do compilador, mas não deveriam ser. Eles explicitamente afetam os resultados dos cálculos.

Se uma otimização do compilador causar resultados diferentes, isso é um bug - um bug no compilador. Confiar em um erro no compilador, a longo prazo, é um erro - o que acontece quando ele é consertado?

O uso de sinalizadores de compilador que alteram o funcionamento dos cálculos deve ser bem documentado, mas usado conforme necessário.

    
por 16.10.2017 / 00:22
fonte
0

Todas as tentativas de código eficiente escritas em qualquer coisa, menos em montagens, dependem muito das otimizações do compilador, começando com as mais básicas, como evitar alocações de registros supérfluas em todo lugar e pelo menos razoavelmente boas, se não excelentes. seleção de instruções. Caso contrário, estaríamos de volta aos anos 80, onde teríamos que colocar register sugestões em todo o lugar e usar o número mínimo de variáveis em uma função para ajudar compiladores C arcaicos ou até mesmo anteriormente quando goto fosse uma otimização de ramificação útil.

Se não sentíssemos que poderíamos confiar na capacidade do nosso otimizador de otimizar nosso código, todos nós ainda estaríamos codificando caminhos de execução críticos para o desempenho na montagem.

É realmente uma questão de quão confiável você acha que a otimização pode ser feita, a qual é melhor resolvida ao analisar os recursos dos compiladores que você tem e, possivelmente, até mesmo desmontar, se houver um hotspot em que você não consiga descobrir onde compilador parece ter falhado em fazer uma otimização óbvia.

O RVO é algo que existe há muito tempo e, pelo menos, excluindo casos muito complexos, é algo que os compiladores têm aplicado com segurança há anos. Definitivamente não vale a pena trabalhar em torno de um problema que não existe.

Errar do lado de confiar no otimizador, não temendo

Ao contrário, eu diria que errar é confiar muito em otimizações de compilador do que em muito pouco, e essa sugestão vem de um cara que trabalha em campos de desempenho crítico onde a eficiência, a capacidade de manutenção e a qualidade percebida entre os clientes é tudo um borrão gigante. Eu preferiria que você confiasse muito confiantemente no seu otimizador e encontrasse alguns casos obscuros em que confiasse demais do que depender muito pouco e apenas codificasse medos supersticiosos o tempo todo para o resto de sua vida. Isso pelo menos fará com que você busque um perfilador e investigue adequadamente se as coisas não forem executadas tão rapidamente quanto deveriam e, ao longo do caminho, obter conhecimentos valiosos, e não superstições.

Você está indo bem para se apoiar no otimizador. Mantem. Não se torne como aquele cara que começa a solicitar explicitamente que todas as funções sejam chamadas em loop antes mesmo de sair de um medo equivocado das deficiências do otimizador.

Perfil

A criação de perfis é realmente a rotatória, mas a resposta final à sua pergunta. O problema que os iniciantes, ávidos para escrever código eficiente, muitas vezes lutam com não é o que otimizar, é o que não otimiza, porque eles desenvolvem todos os tipos de intuições equivocadas sobre ineficiências que, embora humanamente intuitivas, são computacionalmente erradas. Desenvolver a experiência com um profiler começará realmente dando a você uma apreciação adequada não apenas dos recursos de otimização de seus compiladores nos quais você pode confiar com confiança, mas também dos recursos (bem como das limitações) de seu hardware. Há ainda mais valor na criação de perfis para aprender o que não vale a pena otimizar do que aprender o que foi.

    
por 30.11.2017 / 13:06
fonte
0

Não.

Isso é o que eu faço o tempo todo. Se eu precisar acessar um bloco arbitrário de 16 bits na memória, eu faço isso

void *ptr = get_pointer();
uint16_t u16;
memcpy(&u16, ptr, sizeof(u16)); // ntohs omitted for simplicity

... e confie no compilador fazendo o que for possível para otimizar essa parte do código. O código funciona em ARM, i386, AMD64 e praticamente em todas as arquiteturas existentes. Em teoria, um compilador não otimizador poderia realmente chamar memcpy , resultando em desempenho totalmente ruim, mas isso não é problema para mim, já que eu uso otimizações de compilador.

Considere a alternativa:

void *ptr = get_pointer();
uint16_t *u16ptr = ptr;
uint16_t u16;
u16 = *u16ptr;  // ntohs omitted for simplicity

Esse código alternativo não funciona em máquinas que requerem alinhamento adequado, se get_pointer() retornar um ponteiro não alinhado. Além disso, pode haver problemas de alias na alternativa.

A diferença entre -O2 e -O0 ao usar o truque memcpy é ótima: 3,2 Gbps de desempenho de soma de verificação de IP versus 67 Gbps de desempenho de soma de verificação de IP. Mais de uma ordem de grandeza de diferença!

Às vezes você pode precisar ajudar o compilador. Assim, por exemplo, em vez de depender do compilador para desenrolar loops, você pode fazer isso sozinho. Seja implementando o famoso dispositivo da Duff , ou de uma forma mais limpa.

A desvantagem de confiar nas otimizações do compilador é que, se você executar o gdb para depurar seu código, poderá descobrir que muito foi otimizado. Então, você pode precisar recompilar com -O0, o que significa que o desempenho será totalmente ruim durante a depuração. Acho que essa é uma desvantagem que vale a pena considerar, considerando os benefícios de otimizar compiladores.

Não importa o que você faça, certifique-se de que o seu caminho não seja um comportamento indefinido. Certamente, acessar algum bloco aleatório de memória como um inteiro de 16 bits é um comportamento indefinido devido a problemas de alias e alinhamento.

    
por 31.12.2017 / 20:05
fonte
-1

O software pode ser escrito em C ++ em plataformas muito diferentes e para muitas finalidades diferentes.

Depende completamente do propósito do software. Deve ser fácil manter, expandir, corrigir, refatorar et.c. ou são outras coisas mais importantes, como desempenho, custo ou compatibilidade com algum hardware específico ou o tempo necessário para o desenvolvimento.

    
por 13.10.2017 / 18:48
fonte
-2

Acho que a resposta chata para isso é: 'depende'.

É uma prática ruim escrever código que depende de uma otimização do compilador que provavelmente está desativada e onde a vulnerabilidade não está documentada e onde o código em questão não é testado em unidade de modo que se ele quebrasse você saberia ? Provavelmente.

É uma prática ruim escrever código que depende de uma otimização de compilador que não é provável que seja desativada , que esteja documentada e seja testada em unidade ? Talvez não.

    
por 13.10.2017 / 22:54
fonte
-6

A não ser que você não esteja mais nos contando, isso é uma prática ruim, mas não pelo motivo que você sugere.

Possivelmente ao contrário de outras linguagens que você usou antes, retornar o valor de um objeto em C ++ produz uma cópia do objeto. Se você modificar o objeto, você está modificando um objeto diferente . Ou seja, se eu tiver Obj a; a.x=1; e Obj b = a; , então eu faço b.x += 2; b.f(); , então a.x ainda é igual a 1, não a 3.

Portanto, não, usar um objeto como um valor em vez de como uma referência ou ponteiro não fornece a mesma funcionalidade e você pode acabar com erros em seu software.

Talvez você saiba disso e isso não afete negativamente seu caso de uso específico. No entanto, com base no texto da sua pergunta, parece que você pode não estar ciente da distinção; redação como "criar um objeto na função".

"criar um objeto na função" soa como new Obj; onde "retornar o objeto por valor" soa como Obj a; return a;

Obj a; e Obj* a = new Obj; são coisas muito, muito diferentes; o primeiro pode resultar em corrupção de memória, se não for usado e compreendido corretamente, e o segundo pode resultar em vazamentos de memória, se não for usado e compreendido corretamente.

    
por 12.10.2017 / 20:09
fonte
-7

Pieter B está absolutamente correto em recomendar menos espanto.

Para responder a sua pergunta específica, o que isso (mais provavelmente) significa em C ++ é que você deve retornar um std::unique_ptr para o objeto construído.

A razão é que isso é mais claro para um desenvolvedor de C ++ sobre o que está acontecendo.

Embora sua abordagem provavelmente funcione, você está efetivamente sinalizando que o objeto é um tipo de valor pequeno quando, na verdade, não é. Além disso, você está jogando fora qualquer possibilidade de abstração de interface. Isso pode ser OK para seus propósitos atuais, mas é muito útil quando se trata de matrizes.

Eu aprecio que se você veio de outras línguas, todos os sigilos podem ser confusos inicialmente. Mas tenha cuidado para não presumir que, ao não usá-los, você torna seu código mais claro. Na prática, o oposto provavelmente é verdadeiro.

    
por 12.10.2017 / 15:23
fonte