Parece haver equivalentes aproximados de instruções para equacionar o custo de uma ramificação que as funções virtuais têm uma troca semelhante:
- falta de instrução vs. cache de dados
- barreira de otimização
Se você olhar para algo como:
if (x==1) {
p->do1();
}
else if (x==2) {
p->do2();
}
else if (x==3) {
p->do3();
}
...
Você pode ter uma matriz de funções membro ou, se muitas funções dependerem da mesma categorização, ou se existir uma categorização mais complexa, use funções virtuais:
p->do()
Mas, em geral, quão caras são as funções virtuais e as ramificações É difícil testar em plataformas suficientes para generalizar, então eu queria saber se alguém tinha uma regra prática (adorável se fosse tão simples quanto 4 if
s é o ponto de interrupção)
Em geral, as funções virtuais são mais claras e eu me inclinaria para elas. Porém, tenho várias seções altamente críticas nas quais posso alterar o código de funções virtuais para ramificações. Eu preferiria ter pensamentos sobre isso antes de fazer isso. (não é uma alteração trivial ou fácil de testar em várias plataformas)
fonte
Respostas:
Eu queria pular aqui entre essas respostas já excelentes e admitir que adotei a abordagem feia de realmente retroceder ao antipadrão de alterar o código polimórfico em
switches
ouif/else
ramificar com ganhos medidos. Mas não fiz isso por atacado, apenas pelos caminhos mais críticos. Não precisa ser tão preto e branco.Refatoração polimórfica de condicionais
Primeiro, vale a pena entender por que o polimorfismo pode ser preferível a partir de um aspecto de manutenção do que a ramificação condicional (
switch
ou váriasif/else
instruções). O principal benefício aqui é a extensibilidade .Com o código polimórfico, podemos introduzir um novo subtipo em nossa base de código, adicionar instâncias a alguma estrutura de dados polimórficos e fazer com que todo o código polimórfico existente ainda funcione automagicamente, sem novas modificações. Se você tiver um monte de código espalhado por uma grande base de código que se assemelha à forma de "Se esse tipo for 'foo', faça isso" , você poderá se deparar com um fardo horrível de atualizar 50 seções díspares de código para introduzir um novo tipo de coisa, e ainda acabam perdendo algumas.
Os benefícios de manutenção do polimorfismo diminuem naturalmente aqui se você tiver apenas algumas ou apenas uma seção da sua base de código que precisa fazer verificações desse tipo.
Barreira de otimização
Eu sugeriria não olhar para isso do ponto de vista de ramificação e pipelining, e analisar mais a partir da mentalidade de design do compilador das barreiras de otimização. Existem maneiras de melhorar a previsão de ramificação que se aplicam a ambos os casos, como classificar dados com base no subtipo (se ele se encaixa em uma sequência).
O que difere mais entre essas duas estratégias é a quantidade de informações que o otimizador tem antecipadamente. Uma chamada de função conhecida fornece muito mais informações, uma chamada indireta de função que chama uma função desconhecida em tempo de compilação leva a uma barreira de otimização.
Quando a função que está sendo chamada é conhecida, os compiladores podem obliterar a estrutura e reduzi-la a pedacinhos, alinhando chamadas, eliminando a sobrecarga de aliasing potencial, fazendo um trabalho melhor na alocação de instruções / registros, possivelmente até reorganizando loops e outras formas de ramificações, gerando dificuldades LUTs em miniatura codificadas quando apropriado (algo que o GCC 5.3 recentemente me surpreendeu com uma
switch
declaração usando uma LUT de dados codificada para os resultados, em vez de uma tabela de salto).Alguns desses benefícios se perdem quando começamos a introduzir incógnitas em tempo de compilação no mix, como no caso de uma chamada de função indireta, e é aí que a ramificação condicional provavelmente pode oferecer uma vantagem.
Otimização de memória
Tomemos um exemplo de um videogame que consiste em processar uma sequência de criaturas repetidamente em um circuito fechado. Nesse caso, podemos ter um contêiner polimórfico como este:
Nota: por simplicidade, evitei
unique_ptr
aqui.... onde
Creature
é um tipo de base polimórfica. Nesse caso, uma das dificuldades dos contêineres polimórficos é que eles geralmente desejam alocar memória para cada subtipo separadamente / individualmente (por exemplo: usando o lançamento padrãooperator new
para cada criatura individual).Isso geralmente fará a primeira priorização da otimização (se necessário) com base na memória, em vez de ramificação. Uma estratégia aqui é usar um alocador fixo para cada subtipo, incentivando uma representação contígua alocando em grandes pedaços e agrupando memória para cada subtipo que está sendo alocado. Com essa estratégia, pode definitivamente ajudar a classificar esse
creatures
contêiner por sub-tipo (assim como endereço), pois isso não apenas melhora a previsão de ramificação, mas também melhora a localidade de referência (permitindo que várias criaturas do mesmo subtipo sejam acessadas de uma única linha de cache antes do despejo).Desirtualização parcial de estruturas e loops de dados
Digamos que você passou por todos esses movimentos e ainda deseja mais velocidade. Vale a pena notar que cada passo que arriscamos aqui é uma capacidade de manutenção degradante e já estaremos em um estágio de moagem de metal com retornos de desempenho decrescentes. Portanto, é preciso haver uma demanda de desempenho bastante significativa se entrarmos neste território, onde estamos dispostos a sacrificar a capacidade de manutenção ainda mais para obter ganhos de desempenho cada vez menores.
No entanto, o próximo passo a ser tentado (e sempre com a disposição de rever nossas mudanças, se não ajudar em nada) pode ser a destirtualização manual.
No entanto, não precisamos aplicar essa mentalidade por atacado. Continuando nosso exemplo, digamos que este videogame seja composto principalmente de criaturas humanas, de longe. Nesse caso, podemos desvirtualizar apenas criaturas humanas, elevando-as e criando uma estrutura de dados separada apenas para elas.
Isso implica que todas as áreas em nossa base de código que precisam processar criaturas precisam de um loop de caso especial separado para criaturas humanas. No entanto, isso elimina a sobrecarga dinâmica de envio (ou talvez, mais apropriadamente, a barreira de otimização) para humanos, que são, de longe, o tipo de criatura mais comum. Se essas áreas são grandes em número e podemos pagar, podemos fazer o seguinte:
... se pudermos pagar, os caminhos menos críticos podem permanecer como estão e simplesmente processar todos os tipos de criaturas abstratamente. Os caminhos críticos podem processar
humans
em um loop eother_creatures
em um segundo loop.Podemos estender essa estratégia conforme necessário e potencialmente obter alguns ganhos dessa maneira, mas vale a pena observar o quanto estamos degradando a capacidade de manutenção no processo. O uso de modelos de função aqui pode ajudar a gerar o código para humanos e criaturas sem duplicar a lógica manualmente.
Desirtualização parcial de classes
Algo que fiz anos atrás, que era realmente nojento, e nem tenho mais certeza de que seja benéfico (isso foi na era C ++ 03), foi a destirtualização parcial de uma classe. Nesse caso, já estávamos armazenando um ID de classe com cada instância para outros fins (acessado por meio de um acessador na classe base que não era virtual). Lá fizemos algo análogo a isso (minha memória é um pouco nebulosa):
... onde
virtual_do_something
foi implementado para chamar versões não virtuais em uma subclasse. É nojento, eu sei, fazer um downcast estático explícito para desvirtualizar uma chamada de função. Não tenho ideia de como isso é benéfico agora, pois não tenho tentado esse tipo de coisa há anos. Com uma exposição ao design orientado a dados, achei a estratégia acima de dividir estruturas e loops de dados de maneira quente / fria muito mais útil, abrindo mais portas para estratégias de otimização (e muito menos feias).Atacado de Desirtualização
Devo admitir que nunca cheguei tão longe aplicando uma mentalidade de otimização, por isso não tenho idéia dos benefícios. Evitei funções indiretas na previsão nos casos em que sabia que haveria apenas um conjunto central de condicionais (por exemplo: processamento de eventos com apenas um evento de processamento de local central), mas nunca comecei com uma mentalidade polimórfica e otimizei todo o caminho até aqui.
Teoricamente, os benefícios imediatos aqui podem ser uma maneira potencialmente menor de identificar um tipo do que um ponteiro virtual (por exemplo, um único byte se você puder se comprometer com a idéia de que existem 256 tipos exclusivos ou menos), além de eliminar completamente essas barreiras de otimização .
Em alguns casos, também pode ajudar a escrever código mais fácil de manter (em comparação com os exemplos otimizados de desirtualização manual acima) se você usar apenas uma
switch
instrução central sem precisar dividir suas estruturas de dados e loops com base no subtipo ou se houver um pedido Dependência nesses casos em que as coisas precisam ser processadas em uma ordem precisa (mesmo que isso nos faça ramificar por todo o lugar). Isso seria nos casos em que você não tem muitos lugares que precisam fazer issoswitch
.Geralmente, eu não recomendaria isso, mesmo com uma mentalidade muito crítica para o desempenho, a menos que seja razoavelmente fácil de manter. "Fácil de manter" tenderia a depender de dois fatores dominantes:
... no entanto, eu recomendo o cenário acima na maioria dos casos e iterando em direção a soluções mais eficientes por meio da desirtualização parcial, conforme necessário. Ele oferece muito mais espaço para equilibrar as necessidades de extensibilidade e manutenção com desempenho.
Funções virtuais vs. ponteiros de função
Para completar, notei aqui que havia alguma discussão sobre funções virtuais versus ponteiros de função. É verdade que as funções virtuais exigem um pouco de trabalho extra para serem chamadas, mas isso não significa que elas são mais lentas. Contra-intuitivamente, pode até torná-los mais rápidos.
É contra-intuitivo aqui, porque estamos acostumados a medir o custo em termos de instruções, sem prestar atenção à dinâmica da hierarquia de memória, que tende a ter um impacto muito mais significativo.
Se estivermos comparando um
class
com 20 funções virtuais versus umstruct
que armazena 20 ponteiros de função e ambos são instanciados várias vezes, a sobrecarga de memória de cadaclass
instância, neste caso, 8 bytes para o ponteiro virtual em máquinas de 64 bits, enquanto a memória sobrecarga dostruct
é de 160 bytes.O custo prático pode ter muito mais erros de cache obrigatórios e não obrigatórios na tabela de ponteiros de função em comparação à classe usando funções virtuais (e possivelmente falhas de página em uma escala de entrada suficientemente grande). Esse custo tende a diminuir o trabalho ligeiramente extra de indexar uma tabela virtual.
Também lidei com bases de código C legadas (mais antigas que eu) em que tornar esses
structs
indicadores de função preenchidos e instanciado várias vezes, na verdade, proporcionaram ganhos de desempenho significativos (mais de 100% de melhoria), transformando-os em classes com funções virtuais e simplesmente devido à redução maciça no uso de memória, ao aumento da facilidade de cache, etc.Por outro lado, quando as comparações se tornam mais sobre maçãs com maçãs, também encontrei a mentalidade oposta de traduzir de uma mentalidade de função virtual C ++ para uma mentalidade de ponteiro de função de estilo C para ser útil nesses tipos de cenários:
... onde a classe estava armazenando uma única função desprezível moderada (ou duas, se contarmos o destruidor virtual). Nesses casos, pode definitivamente ajudar em caminhos críticos para transformar isso em:
... idealmente atrás de uma interface de tipo seguro para ocultar os lançamentos perigosos de / para
void*
.Nos casos em que somos tentados a usar uma classe com uma única função virtual, ela pode ajudar rapidamente a usar ponteiros de função. Um grande motivo nem sequer é necessariamente o custo reduzido ao chamar um ponteiro de função. É porque não enfrentamos mais a tentação de alocar cada função separada nas regiões dispersas da pilha, se as estivermos agregando a uma estrutura persistente. Esse tipo de abordagem pode facilitar a sobrecarga associada à pilha e à fragmentação da memória se os dados da instância forem homogêneos, por exemplo, e apenas o comportamento variar.
Definitivamente, há alguns casos em que o uso de ponteiros de função pode ajudar, mas muitas vezes eu encontrei o contrário, se estamos comparando várias tabelas de ponteiros de função com uma única tabela, que requer apenas que um ponteiro seja armazenado por instância de classe . Essa vtable geralmente fica em uma ou mais linhas de cache L1, bem como em loops apertados.
Conclusão
Enfim, essa é a minha pequena opinião sobre esse tópico. Eu recomendo se aventurar nessas áreas com cautela. As medições de confiança, não o instinto, e dada a maneira como essas otimizações geralmente degradam a capacidade de manutenção, vão tão longe quanto você pode pagar (e uma rota sensata seria errar no lado da manutenção).
fonte
Observações:
Em muitos casos, as funções virtuais são mais rápidas porque a consulta da vtable é uma
O(1)
operação, enquanto aelse if()
escada é umaO(n)
operação. No entanto, isso só é verdade se a distribuição dos casos for plana.Para um único
if() ... else
, o condicional é mais rápido porque você salva a sobrecarga da chamada de função.Portanto, quando você tem uma distribuição plana de casos, um ponto de equilíbrio deve existir. A única questão é onde está localizado.
Se você usar um
switch()
em vez deelse if()
escada ou função virtual chamadas, o compilador pode produzir ainda melhor código: ele pode fazer uma filial para um local que é olhou por cima de uma mesa, mas que não é uma chamada de função. Ou seja, você tem todas as propriedades da chamada de função virtual sem toda a sobrecarga da chamada de função.Se um for muito mais frequente que o resto, iniciar um
if() ... else
com esse caso fornecerá o melhor desempenho: Você executará um único ramo condicional predito corretamente na maioria dos casos.Seu compilador não tem conhecimento da distribuição esperada de casos e assumirá uma distribuição plana.
Como seu compilador provavelmente possui boas heurísticas em relação a quando codificar a
switch()
como umaelse if()
escada ou como uma pesquisa de tabela. Eu tenderia a confiar em seu julgamento, a menos que você saiba que a distribuição dos casos é tendenciosa.Então, meu conselho é este:
Se um dos casos supera o restante em termos de frequência, use uma
else if()
escada classificada .Caso contrário, use uma
switch()
instrução, a menos que um dos outros métodos torne seu código muito mais legível. Certifique-se de não adquirir um ganho de desempenho negligenciável com legibilidade significativamente reduzida.Se você usou
switch()
e ainda não está satisfeito com o desempenho, faça a comparação, mas esteja preparado para descobrir que essaswitch()
já era a possibilidade mais rápida.fonte
O(1)
eO(n)
existe umk
para que aO(n)
função seja maior que aO(1)
função para todosn >= k
. A única questão é se é provável que você tenha tantos casos. E, sim, viswitch()
declarações com tantos casos que umaelse if()
escada é definitivamente mais lenta que uma chamada de função virtual ou um despacho carregado.if
vs.switch
vs. virtuais com base no desempenho. Em casos extremamente raros , pode ser, mas na maioria dos casos não é.Em geral, sim. Os benefícios para a manutenção são significativos (testes em separação, separação de preocupações, modularidade e extensibilidade aprimoradas).
A menos que você tenha perfilado seu código e saiba que o envio entre filiais ( a avaliação das condições ) leva mais tempo que os cálculos realizados ( o código nas filiais ), otimize os cálculos realizados.
Ou seja, a resposta correta para "qual é o custo das funções virtuais versus ramificação" é medida e descoberta.
Regra geral : a menos que tenha a situação acima (discriminação de ramificação mais cara que cálculos de ramificação), otimize esta parte do código para o esforço de manutenção (use funções virtuais).
Você diz que deseja que esta seção seja executada o mais rápido possível; Quão rápido é isso? Qual é a sua exigência concreta?
Use funções virtuais então. Isso permitirá que você otimize por plataforma, se necessário, e ainda mantenha o código do cliente limpo.
fonte
As outras respostas já fornecem bons argumentos teóricos. Gostaria de adicionar os resultados de um experimento que realizei recentemente para estimar se seria uma boa ideia implementar uma máquina virtual (VM) usando um
switch
código grande acima do código operacional ou, melhor dizendo, interpretá-lo como um índice em uma matriz de ponteiros de função. Embora isso não seja exatamente o mesmo que umavirtual
chamada de função, acho que é razoavelmente próximo.Eu escrevi um script Python para gerar aleatoriamente o código C ++ 14 para uma VM com um tamanho de conjunto de instruções escolhido aleatoriamente (embora não uniformemente, amostrando o intervalo baixo mais densamente) entre 1 e 10000. A VM gerada sempre teve 128 registros e nenhum RAM. As instruções não são significativas e todas têm o seguinte formato.
O script também gera rotinas de despacho usando uma
switch
instrução…… E uma matriz de ponteiros de função.
Qual rotina de despacho foi gerada foi escolhida aleatoriamente para cada VM gerada.
Para o benchmarking, o fluxo de códigos op foi gerado por um
std::random_device
mecanismo aleatório aleatório (std::mt19937_64
) de twister Mersenne ( ).O código para cada VM foi compilado com o GCC 5.2.0 usando os comutadores
-DNDEBUG
,-O3
e-std=c++14
. Primeiro, foi compilado usando a-fprofile-generate
opção e os dados de perfil coletados para simular 1000 instruções aleatórias. O código foi recompilado com a-fprofile-use
opção permitindo otimizações com base nos dados de perfil coletados.A VM foi então exercitada (no mesmo processo) quatro vezes por 50 000 000 ciclos e o tempo para cada execução foi medido. A primeira execução foi descartada para eliminar os efeitos de cache frio. O PRNG não foi reproduzido novamente entre as execuções, para que não executassem a mesma sequência de instruções.
Usando essa configuração, 1000 pontos de dados para cada rotina de expedição foram coletados. Os dados foram coletados em uma APU AMD A8-6600K de quatro núcleos com cache de 2048 KiB executando GNU / Linux de 64 bits sem uma área de trabalho gráfica ou outros programas em execução. A seguir, é mostrado um gráfico do tempo médio da CPU (com desvio padrão) por instrução para cada VM.
A partir desses dados, eu poderia ter certeza de que usar uma tabela de funções é uma boa idéia, exceto talvez para um número muito pequeno de códigos operacionais. Não tenho uma explicação para os outliers da
switch
versão entre 500 e 1000 instruções.Todo o código fonte da referência, bem como os dados experimentais completos e um gráfico de alta resolução podem ser encontrados no meu site .
fonte
Além da boa resposta do cmaster, que eu votei, lembre-se de que os indicadores de função geralmente são estritamente mais rápidos que as funções virtuais. O despacho de funções virtuais geralmente envolve primeiro seguir um ponteiro do objeto para a tabela, indexar adequadamente e, em seguida, desreferenciar um ponteiro de função. Portanto, a etapa final é a mesma, mas há etapas extras inicialmente. Além disso, as funções virtuais sempre tomam "isso" como argumento, os ponteiros de função são mais flexíveis.
Outra coisa a ter em mente: se o caminho crítico envolve um loop, pode ser útil classificá-lo por destino de despacho. Obviamente, isso é nlogn, enquanto que percorrer o loop é apenas n, mas se você for percorrer muitas vezes isso pode valer a pena. Ao classificar por destino de despacho, você garante que o mesmo código seja executado repetidamente, mantendo-o quente no icache, minimizando as perdas de cache.
Uma terceira estratégia a ter em mente: se você decidir se afastar das funções virtuais / ponteiros de função em direção às estratégias if / switch, também poderá ser bem atendido alternando de objetos polimórficos para algo como boost :: variant (que também fornece a opção caso na forma de abstração do visitante). Objetos polimórficos precisam ser armazenados pelo ponteiro base, para que seus dados estejam em todo o lugar no cache. Isso pode facilmente ter uma influência maior no caminho crítico do que o custo da pesquisa virtual. Considerando que a variante é armazenada em linha como uma união discriminada; tem tamanho igual ao maior tipo de dados (mais uma pequena constante). Se seus objetos não diferem muito em tamanho, essa é uma ótima maneira de lidar com eles.
Na verdade, eu não ficaria surpreso se a melhoria da coerência do cache dos seus dados tivesse um impacto maior do que a sua pergunta original, então eu definitivamente investigaria mais.
fonte
Posso apenas explicar por que acho que esse é um problema XY ? (Você não está sozinho em perguntar a eles.)
Suponho que seu objetivo real é economizar tempo em geral, não apenas entender um ponto sobre falhas de cache e funções virtuais.
Aqui está um exemplo de ajuste de desempenho real , em software real.
Em software real, as coisas são feitas que, não importa quão experiente o programador seja, poderiam ser feitas melhor. Não se sabe o que são até que o programa seja escrito e o ajuste de desempenho possa ser feito. Quase sempre há mais de uma maneira de acelerar o programa. Afinal, para dizer que um programa é ideal, você está dizendo que, no panteão de possíveis programas para resolver seu problema, nenhum deles leva menos tempo. Sério?
No exemplo ao qual vinculei, ele levou originalmente 2700 microssegundos por "trabalho". Uma série de seis problemas foram resolvidos, girando no sentido anti-horário ao redor da pizza. A primeira aceleração removeu 33% das vezes. O segundo removeu 11%. Mas observe, o segundo não era de 11% no momento em que foi encontrado, era de 16%, porque o primeiro problema havia desaparecido . Da mesma forma, o terceiro problema foi ampliado de 7,4% para 13% (quase o dobro), porque os dois primeiros problemas se foram.
No final, esse processo de ampliação permitiu eliminar quase 3,7 microssegundos. Isso representa 0,14% do tempo original ou uma aceleração de 730x.
A remoção dos problemas inicialmente grandes fornece uma quantidade moderada de aceleração, mas abre o caminho para a remoção de problemas posteriores. Esses problemas posteriores poderiam inicialmente ter sido partes insignificantes do total, mas depois que os problemas iniciais são removidos, esses pequenos se tornam grandes e podem produzir grandes acelerações. (É importante entender que, para obter esse resultado, nada pode ser desperdiçado, e esta postagem mostra como eles podem ser facilmente).
O programa final foi ideal? Provavelmente não. Nenhuma das acelerações teve algo a ver com falhas de cache. As falhas de cache importariam agora? Talvez.
EDIT: Estou recebendo votos negativos de pessoas que se encontram nas "seções altamente críticas" da pergunta do OP. Você não sabe que algo é "altamente crítico" até saber qual fração do tempo representa. Se o custo médio desses métodos chamados for de 10 ciclos ou mais, com o tempo, o método de envio a eles provavelmente não é "crítico", comparado ao que eles estão realmente fazendo. Vejo isso repetidamente, onde as pessoas tratam "a necessidade de cada nanossegundo" como uma razão para serem tostões e tolos.
fonte