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
ou if/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.
Como aviso de isenção de responsabilidade, trabalho em áreas como raytracing, onde a correção não é tão difícil de alcançar (e muitas vezes é confusa e aproximada de qualquer maneira), enquanto a velocidade é frequentemente uma das qualidades mais competitivas procuradas. Uma redução no tempo de renderização costuma ser uma das solicitações mais comuns do usuário, com a gente constantemente coçando a cabeça e descobrindo como alcançá-la para os caminhos medidos mais críticos.
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árias if/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:
vector<Creature*> creatures;
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ão operator 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.
Dica de controle de versão: a menos que você seja muito mais experiente em otimização do que eu, pode valer a pena criar uma nova filial neste momento com a disposição de descartá-la se nossos esforços de otimização falharem, o que pode muito bem acontecer. Para mim, é tudo tentativa e erro após esses tipos de pontos, mesmo com um criador de perfil na mão.
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.
vector<Human> humans; // common case
vector<Creature*> other_creatures; // additional rare-case creatures
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:
vector<Human> humans; // common case
vector<Creature*> other_creatures; // additional rare-case creatures
vector<Creature*> creatures; // contains humans and other creatures
... 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 e other_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):
switch (obj->type())
{
case id_common_type:
static_cast<CommonType*>(obj)->non_virtual_do_something();
break;
...
default:
obj->virtual_do_something();
break;
}
... 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 isso switch
.
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:
- Não ter uma necessidade real de extensibilidade (por exemplo: sabendo com certeza que você tem exatamente 8 tipos de coisas para processar e nunca mais).
- Não há muitos locais no seu código que precisam verificar esses tipos (por exemplo, um local central).
... 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 um struct
que armazena 20 ponteiros de função e ambos são instanciados várias vezes, a sobrecarga de memória de cada class
instância, neste caso, 8 bytes para o ponteiro virtual em máquinas de 64 bits, enquanto a memória sobrecarga do struct
é 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:
class Functionoid
{
public:
virtual ~Functionoid() {}
virtual void operator()() = 0;
};
... 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:
void (*func_ptr)(void* instance_data);
... 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).