Qual é o custo de desempenho de ter um método virtual em uma classe C ++?

107

Ter pelo menos um método virtual em uma classe C ++ (ou qualquer uma de suas classes pai) significa que a classe terá uma tabela virtual e cada instância terá um ponteiro virtual.

Portanto, o custo da memória é bastante claro. O mais importante é o custo de memória nas instâncias (especialmente se as instâncias são pequenas, por exemplo se elas foram destinadas apenas a conter um inteiro: neste caso, ter um ponteiro virtual em cada instância pode dobrar o tamanho das instâncias. o espaço de memória usado pelas tabelas virtuais, acho que geralmente é insignificante em comparação com o espaço usado pelo código do método real.

Isso me leva à minha pergunta: há um custo de desempenho mensurável (ou seja, impacto na velocidade) para tornar um método virtual? Haverá uma consulta na tabela virtual em tempo de execução, a cada chamada de método, portanto, se houver chamadas muito frequentes para esse método e se este método for muito curto, pode haver um impacto mensurável no desempenho? Acho que depende da plataforma, mas alguém já executou alguns benchmarks?

A razão pela qual estou perguntando é que me deparei com um bug que aconteceu devido ao esquecimento de um programador de definir um método virtual. Esta não é a primeira vez que vejo esse tipo de erro. E eu pensei: por que adicionar a palavra-chave virtual quando necessário, em vez de remover a palavra-chave virtual quando estamos absolutamente certo de que ele é não é necessário? Se o custo de desempenho for baixo, acho que vou simplesmente recomendar o seguinte para minha equipe: simplesmente tornar todos os métodos virtuais por padrão, incluindo o destruidor, em todas as classes e removê-lo apenas quando for necessário. Isso parece loucura para você?

MiniQuark
fonte
7
Comparar chamadas virtuais com não virtuais não é nada complicado. Eles fornecem funcionalidades diferentes. Se você deseja comparar chamadas de função virtual com o equivalente C, você precisa adicionar o custo do código que implementa o recurso equivalente da função virtual.
Martin York
Que é uma instrução switch ou uma grande instrução if. Se você fosse inteligente, poderia reimplementar usando uma tabela de ponteiro de função, mas as probabilidades de errar são muito maiores.
Martin York
7
A questão é sobre chamadas de função que não precisam ser virtuais, portanto, a comparação é significativa.
Mark Ransom,

Respostas:

104

Eu executei alguns timings em um processador PowerPC de 3 GHz em ordem. Nessa arquitetura, uma chamada de função virtual custa 7 nanossegundos a mais do que uma chamada de função direta (não virtual).

Portanto, não vale a pena se preocupar com o custo, a menos que a função seja algo como um acessador Get () / Set () trivial, no qual qualquer coisa que não seja inline é um desperdício. Uma sobrecarga de 7ns em uma função que alinha a 0,5ns é grave; uma sobrecarga de 7ns em uma função que leva 500 ms para ser executada não faz sentido.

O grande custo das funções virtuais não é realmente a pesquisa de um ponteiro de função na vtable (que geralmente é apenas um único ciclo), mas que o salto indireto geralmente não pode ser previsto por ramo. Isso pode causar uma grande bolha no pipeline, pois o processador não pode buscar nenhuma instrução até que o salto indireto (a chamada por meio do ponteiro de função) seja retirado e um novo ponteiro de instrução seja calculado. Portanto, o custo de uma chamada de função virtual é muito maior do que pode parecer olhando para o assembly ... mas ainda apenas 7 nanossegundos.

Edit: Andrew, Not Sure e outros também levantam o ponto muito bom de que uma chamada de função virtual pode causar uma falha no cache de instrução: se você pular para um endereço de código que não está no cache, todo o programa é paralisado enquanto o as instruções são obtidas na memória principal. Isso é sempre uma parada significativa: no Xenon, cerca de 650 ciclos (pelos meus testes).

No entanto, este não é um problema específico para funções virtuais porque até mesmo uma chamada direta de função causará um erro se você pular para instruções que não estão no cache. O que importa é se a função foi executada recentemente (tornando mais provável que ela esteja no cache) e se sua arquitetura pode prever ramificações estáticas (não virtuais) e buscar essas instruções no cache com antecedência. Meu PPC não, mas talvez o hardware mais recente da Intel sim.

Meus timings controlam a influência das falhas do icache na execução (deliberadamente, já que eu estava tentando examinar o pipeline da CPU isoladamente), então eles descontam esse custo.

Crashworks
fonte
3
O custo em ciclos é aproximadamente igual ao número de estágios do pipeline entre a busca e o final da retirada do ramal. Não é um custo insignificante e pode somar, mas a menos que você esteja tentando escrever um loop de alto desempenho, provavelmente haverá um desempenho maior para você fritar.
Crashworks de
7 nano segundos a mais do que o quê. Se uma chamada normal dura 1 nano segundo, o que é digno, se uma chamada normal dura 70 nano segundos, então não é.
Martin York
Se você olhar os intervalos, descobri que para uma função que custava 0,66 ns em linha, a sobrecarga diferencial de uma chamada de função direta era 4,8 ns e uma função virtual 12,3 ns (em comparação com a linha). Você ressalta que, se a função em si custar um milissegundo, 7 ns não significa nada.
Crashworks de
2
Mais como 600 ciclos, mas é um bom ponto. Eu o deixei fora dos tempos porque estava interessado apenas no overhead devido à bolha do pipeline e prólogo / epílogo. O icache miss acontece com a mesma facilidade para uma chamada de função direta (o Xenon não tem preditor de branch icache).
Crashworks de
2
Pequenos detalhes, mas em relação a "No entanto, este não é um problema específico para ..." é um pouco pior para o envio virtual, pois há uma página extra (ou duas se acontecer de ultrapassar o limite da página) que deve estar no cache - para a Tabela de Despacho Virtual da classe.
Tony Delroy
19

Definitivamente, há sobrecarga mensurável ao chamar uma função virtual - a chamada deve usar o vtable para resolver o endereço da função para esse tipo de objeto. As instruções extras são a menor das suas preocupações. Os vtables não apenas impedem muitas otimizações potenciais do compilador (uma vez que o tipo é polimórfico do compilador), eles também podem destruir seu I-Cache.

Obviamente, se essas penalidades são significativas ou não, depende de seu aplicativo, da frequência com que esses caminhos de código são executados e de seus padrões de herança.

Na minha opinião, porém, ter tudo como virtual por padrão é uma solução geral para um problema que você poderia resolver de outras maneiras.

Talvez você possa ver como as classes são projetadas / documentadas / escritas. Geralmente, o cabeçalho de uma classe deve deixar bem claro quais funções podem ser substituídas por classes derivadas e como elas são chamadas. Fazer com que os programadores escrevam esta documentação é útil para garantir que eles sejam marcados corretamente como virtuais.

Eu também diria que declarar cada função como virtual pode levar a mais bugs do que apenas esquecer de marcar algo como virtual. Se todas as funções forem virtuais, tudo pode ser substituído por classes base - pública, protegida, privada - tudo se torna um jogo justo. Por acidente ou intenção, as subclasses podem então alterar o comportamento das funções que causam problemas quando usadas na implementação básica.

Andrew Grant
fonte
A maior otimização perdida é o inlining, especialmente se a função virtual costuma ser pequena ou vazia.
Zan Lynx de
@Andrew: ponto de vista interessante. Eu discordo um pouco do seu último parágrafo, entretanto: se uma classe base tem uma função saveque depende de uma implementação específica de uma função writena classe base, então me parece que ou saveestá mal codificada ou writedeveria ser privada.
MiniQuark de
2
Só porque a gravação é privada, isso não impede que seja sobrescrito. Este é outro argumento para não tornar as coisas virtuais por padrão. Em todo caso, eu estava pensando no contrário - uma implementação genérica e bem escrita é substituída por algo que tem um comportamento específico e não compatível.
Andrew Grant,
Votado no cache - em qualquer grande base de código orientado a objeto, se você não estiver seguindo as práticas de desempenho de localidade de código, é muito fácil para suas chamadas virtuais causar perdas de cache e causar uma paralisação.
Não tenho certeza,
E uma paralisação de icache pode ser muito séria: 600 ciclos em meus testes.
Crashworks de
9

Depende. :) (Você esperava mais alguma coisa?)

Uma vez que uma classe obtém uma função virtual, ela não pode mais ser um tipo de dados POD, (pode não ter sido antes também, caso em que isso não fará diferença) e isso torna impossível toda uma série de otimizações.

std :: copy () em tipos POD simples podem recorrer a uma rotina memcpy simples, mas tipos não-POD devem ser tratados com mais cuidado.

A construção se torna muito mais lenta porque a vtable deve ser inicializada. No pior caso, a diferença de desempenho entre os tipos de dados POD e não POD pode ser significativa.

No pior caso, você pode ver uma execução 5x mais lenta (esse número é retirado de um projeto universitário que fiz recentemente para reimplementar algumas classes de biblioteca padrão. Nosso contêiner demorou cerca de 5x mais tempo para ser construído assim que o tipo de dados armazenado recebeu um vtable)

Claro, na maioria dos casos, é improvável que você veja qualquer diferença mensurável de desempenho; isso é simplesmente para apontar que, em alguns casos de fronteira, pode ser caro.

No entanto, o desempenho não deve ser sua principal consideração aqui. Tornar tudo virtual não é uma solução perfeita por outros motivos.

Permitir que tudo seja sobrescrito em classes derivadas torna muito mais difícil manter invariantes de classe. Como uma classe garante que permanecerá em um estado consistente quando qualquer um de seus métodos pode ser redefinido a qualquer momento?

Tornar tudo virtual pode eliminar alguns possíveis bugs, mas também introduz novos.

Jalf
fonte
7

Se você precisa da funcionalidade de envio virtual, tem que pagar o preço. A vantagem do C ++ é que você pode usar uma implementação muito eficiente de despacho virtual fornecido pelo compilador, em vez de uma versão possivelmente ineficiente que você mesmo implemente.

No entanto, sobrecarregar-se com a sobrecarga, se você não precisar, provavelmente está indo longe demais. E a maioria das classes não foi projetada para ser herdada - criar uma boa classe base requer mais do que tornar suas funções virtuais.


fonte
Boa resposta, mas, IMO, não enfático o suficiente na 2ª metade: sobrecarregar-se com as despesas gerais se você não precisar é, francamente, maluco - especialmente quando se usa esta linguagem cujo mantra é "não pague pelo que você usa usar. " Tornar tudo virtual por padrão até que alguém justifique porque pode / deve ser não virtual é uma política abominável.
sublinhado_d
5

O despacho virtual é uma ordem de magnitude mais lento do que algumas alternativas - não devido à indireção, mas à prevenção de inlining. Abaixo, eu ilustro isso contrastando o envio virtual com uma implementação que incorpora um "número de tipo (-identificador)" nos objetos e usando uma instrução switch para selecionar o código específico do tipo. Isso evita completamente a sobrecarga da chamada de função - apenas fazendo um salto local. Há um custo potencial de manutenção, dependências de recompilação, etc. por meio da localização forçada (no switch) da funcionalidade específica do tipo.


IMPLEMENTAÇÃO

#include <iostream>
#include <vector>

// virtual dispatch model...

struct Base
{
    virtual int f() const { return 1; }
};

struct Derived : Base
{
    virtual int f() const { return 2; }
};

// alternative: member variable encodes runtime type...

struct Type
{
    Type(int type) : type_(type) { }
    int type_;
};

struct A : Type
{
    A() : Type(1) { }
    int f() const { return 1; }
};

struct B : Type
{
    B() : Type(2) { }
    int f() const { return 2; }
};

struct Timer
{
    Timer() { clock_gettime(CLOCK_MONOTONIC, &from); }
    struct timespec from;
    double elapsed() const
    {
        struct timespec to;
        clock_gettime(CLOCK_MONOTONIC, &to);
        return to.tv_sec - from.tv_sec + 1E-9 * (to.tv_nsec - from.tv_nsec);
    }
};

int main(int argc)
{
  for (int j = 0; j < 3; ++j)
  {
    typedef std::vector<Base*> V;
    V v;

    for (int i = 0; i < 1000; ++i)
        v.push_back(i % 2 ? new Base : (Base*)new Derived);

    int total = 0;

    Timer tv;

    for (int i = 0; i < 100000; ++i)
        for (V::const_iterator i = v.begin(); i != v.end(); ++i)
            total += (*i)->f();

    double tve = tv.elapsed();

    std::cout << "virtual dispatch: " << total << ' ' << tve << '\n';

    // ----------------------------

    typedef std::vector<Type*> W;
    W w;

    for (int i = 0; i < 1000; ++i)
        w.push_back(i % 2 ? (Type*)new A : (Type*)new B);

    total = 0;

    Timer tw;

    for (int i = 0; i < 100000; ++i)
        for (W::const_iterator i = w.begin(); i != w.end(); ++i)
        {
            if ((*i)->type_ == 1)
                total += ((A*)(*i))->f();
            else
                total += ((B*)(*i))->f();
        }

    double twe = tw.elapsed();

    std::cout << "switched: " << total << ' ' << twe << '\n';

    // ----------------------------

    total = 0;

    Timer tw2;

    for (int i = 0; i < 100000; ++i)
        for (W::const_iterator i = w.begin(); i != w.end(); ++i)
            total += (*i)->type_;

    double tw2e = tw2.elapsed();

    std::cout << "overheads: " << total << ' ' << tw2e << '\n';
  }
}

RESULTADOS DE DESEMPENHO

No meu sistema Linux:

~/dev  g++ -O2 -o vdt vdt.cc -lrt
~/dev  ./vdt                     
virtual dispatch: 150000000 1.28025
switched: 150000000 0.344314
overhead: 150000000 0.229018
virtual dispatch: 150000000 1.285
switched: 150000000 0.345367
overhead: 150000000 0.231051
virtual dispatch: 150000000 1.28969
switched: 150000000 0.345876
overhead: 150000000 0.230726

Isso sugere que uma abordagem comutada por número de tipo em linha é de cerca de (1,28 - 0,23) / (0,344 - 0,23) = 9,2 vezes mais rápida. Claro, isso é específico para o sistema exato testado / sinalizadores do compilador e versão etc., mas geralmente indicativo.


COMENTÁRIOS SOBRE ENVIO VIRTUAL

Deve ser dito, entretanto, que sobrecargas de chamada de função virtual são algo que raramente é significativo, e apenas para funções freqüentemente chamadas de triviais (como getters e setters). Mesmo assim, você pode ser capaz de fornecer uma única função para obter e definir várias coisas de uma vez, minimizando o custo. As pessoas se preocupam demais com o envio virtual - então faça o perfil antes de encontrar alternativas estranhas. O principal problema com eles é que executam uma chamada de função fora da linha, embora também desloquem o código executado, o que altera os padrões de utilização do cache (para melhor ou (mais frequentemente) para pior).

Tony Delroy
fonte
Eu fiz uma pergunta sobre o seu código porque tenho alguns resultados "estranhos" usando g++/ clange -lrt. Achei que valeria a pena mencionar aqui para futuros leitores.
Holt de
@Holt: boa pergunta, dados os resultados misteriosos! Vou dar uma olhada mais de perto em alguns dias, se tiver meia chance. Felicidades.
Tony Delroy
3

O custo extra é virtualmente nada na maioria dos cenários. (perdoe o torcadilho). ejac já publicou medidas relativas sensatas.

A maior coisa de que você desiste são as possíveis otimizações devido ao inlining. Eles podem ser especialmente bons se a função for chamada com parâmetros constantes. Isso raramente faz uma diferença real, mas em alguns casos, pode ser enorme.


Com relação às otimizações:
É importante conhecer e considerar o custo relativo das construções de sua linguagem. A notação Big O é apenas metade da história - como seu aplicativo é dimensionado . A outra metade é o fator constante à sua frente.

Via de regra, eu não sairia do meu caminho para evitar funções virtuais, a menos que haja indicações claras e específicas de que é um gargalo. Um design limpo sempre vem em primeiro lugar - mas é apenas uma parte interessada que não deve prejudicar outros indevidamente .


Exemplo artificial: um destruidor virtual vazio em uma matriz de um milhão de pequenos elementos pode destruir pelo menos 4 MB de dados, destruindo seu cache. Se esse destruidor puder ser sequenciado, os dados não serão alterados.

Ao escrever o código da biblioteca, tais considerações estão longe de ser prematuras. Você nunca sabe quantos loops serão colocados em torno de sua função.

Peterchen
fonte
2

Embora todos estejam corretos sobre o desempenho de métodos virtuais e coisas assim, acho que o verdadeiro problema é se a equipe sabe sobre a definição da palavra-chave virtual em C ++.

Considere este código, qual é a saída?

#include <stdio.h>

class A
{
public:
    void Foo()
    {
        printf("A::Foo()\n");
    }
};

class B : public A
{
public:
    void Foo()
    {
        printf("B::Foo()\n");
    }
};

int main(int argc, char** argv)
{    
    A* a = new A();
    a->Foo();

    B* b = new B();
    b->Foo();

    A* a2 = new B();
    a2->Foo();

    return 0;
}

Nada surpreendente aqui:

A::Foo()
B::Foo()
A::Foo()

Como nada é virtual. Se a palavra-chave virtual for adicionada à frente de Foo nas classes A e B, obtemos isso para a saída:

A::Foo()
B::Foo()
B::Foo()

Praticamente o que todo mundo espera.

Agora, você mencionou que há bugs porque alguém se esqueceu de adicionar uma palavra-chave virtual. Portanto, considere este código (onde a palavra-chave virtual é adicionada à classe A, mas não à classe B). Qual é a saída então?

#include <stdio.h>

class A
{
public:
    virtual void Foo()
    {
        printf("A::Foo()\n");
    }
};

class B : public A
{
public:
    void Foo()
    {
        printf("B::Foo()\n");
    }
};

int main(int argc, char** argv)
{    
    A* a = new A();
    a->Foo();

    B* b = new B();
    b->Foo();

    A* a2 = new B();
    a2->Foo();

    return 0;
}

Resposta: O mesmo que se a palavra-chave virtual fosse adicionada a B? O motivo é que a assinatura de B :: Foo corresponde exatamente a A :: Foo () e, como o Foo de A é virtual, o de B também é.

Agora considere o caso em que o Foo de B é virtual e o de A não é. Qual é a saída então? Neste caso, a saída é

A::Foo()
B::Foo()
A::Foo()

A palavra-chave virtual funciona para baixo na hierarquia, não para cima. Isso nunca torna os métodos da classe base virtuais. A primeira vez que um método virtual é encontrado na hierarquia é quando o polimorfismo começa. Não há como as classes posteriores fazerem com que as classes anteriores tenham métodos virtuais.

Não se esqueça de que os métodos virtuais significam que esta classe está dando às classes futuras a capacidade de substituir / alterar alguns de seus comportamentos.

Portanto, se você tiver uma regra para remover a palavra-chave virtual, ela pode não ter o efeito desejado.

A palavra-chave virtual em C ++ é um conceito poderoso. Você deve certificar-se de que cada membro da equipe realmente conhece esse conceito, para que possa ser usado conforme projetado.

Tommy Hui
fonte
Oi Tommy, obrigado pelo tutorial. O bug que tínhamos era devido à falta de uma palavra-chave "virtual" em um método da classe base. BTW, estou dizendo para tornar todas as funções virtuais (não o oposto), então, quando claramente não for necessário, remova a palavra-chave "virtual".
MiniQuark
@MiniQuark: Tommy Hui está dizendo que se você tornar todas as funções virtuais, um programador pode acabar removendo a palavra-chave em uma classe derivada, sem perceber que ela não tem efeito. Você precisaria de alguma forma para garantir que a remoção da palavra-chave virtual sempre ocorra na classe base.
M. Dudley
1

Dependendo da sua plataforma, a sobrecarga de uma chamada virtual pode ser muito indesejável. Ao declarar todas as funções virtuais, você está essencialmente as chamando por meio de um ponteiro de função. No mínimo, essa é uma desreferência extra, mas em algumas plataformas PPC, ele usará instruções microcodificadas ou lentas para fazer isso.

Eu não recomendo sua sugestão por esse motivo, mas se isso ajudar a prevenir bugs, pode valer a pena negociar. Não posso deixar de pensar que deve haver algum meio-termo que vale a pena encontrar, no entanto.

Dan Olson
fonte
-1

Exigirá apenas algumas instruções extras de conjunto para chamar o método virtual.

Mas não acho que você se preocupe com o fato de fun (int a, int b) ter algumas instruções extras de 'push' em comparação com fun (). Portanto, não se preocupe com os virtuais também, até que você esteja em uma situação especial e veja que isso realmente causa problemas.

PS Se você tiver um método virtual, certifique-se de ter um destruidor virtual. Desta forma, você evitará possíveis problemas


Em resposta aos comentários de 'xtofl' e 'Tom'. Fiz pequenos testes com 3 funções:

  1. Virtual
  2. Normal
  3. Normal com 3 parâmetros internos

Meu teste foi uma iteração simples:

for(int it = 0; it < 100000000; it ++) {
    test.Method();
}

E aqui estão os resultados:

  1. 3.913 s
  2. 3.873 s
  3. 3.970 s

Ele foi compilado por VC ++ em modo de depuração. Eu fiz apenas 5 testes por método e calculei o valor médio (então os resultados podem ser bastante imprecisos) ... De qualquer forma, os valores são quase iguais assumindo 100 milhões de chamadas. E o método com 3 push / pop extras era mais lento.

O ponto principal é que se você não gosta da analogia com o push / pop, pense em if / else extras em seu código? Você pensa no pipeline da CPU quando adiciona if / else ;-) Além disso, você nunca sabe em qual CPU o código estará rodando ... O compilador normal pode gerar código mais otimizado para uma CPU e menos otimizado para outra ( Intel Compilador C ++ )

alex2k8
fonte
2
o conjunto extra pode simplesmente disparar uma falha de página (que não estaria lá para funções não virtuais) - acho que você simplificou enormemente o problema.
xtofl
2
+1 ao comentário de xtofl. As funções virtuais introduzem indireção, que introduzem "bolhas" no pipeline e afetam o comportamento do cache.
Tom,
1
Cronometrar qualquer coisa no modo de depuração não faz sentido. O MSVC cria um código muito lento no modo de depuração e a sobrecarga do loop provavelmente oculta a maior parte da diferença. Se você está almejando alto desempenho, sim, você deve pensar em minimizar as ramificações if / else no caminho rápido. Consulte agner.org/optimize para obter mais informações sobre a otimização de desempenho x86 de baixo nível. (Também alguns outros links na wiki tag x86
Peter Cordes,
1
@Tom: o ponto chave aqui é que as funções não virtuais podem embutir, mas as virtuais não (a menos que o compilador possa desvirtualizar, por exemplo, se você usou finalem sua substituição e você tem um ponteiro para o tipo derivado, ao invés do tipo base ) Este teste sempre chamou a mesma função virtual, então previu perfeitamente; nenhum pipeline borbulha, exceto pelo callrendimento limitado . E aquele indiretocall pode ser mais alguns uops. A previsão de ramificação funciona bem mesmo para ramificações indiretas, especialmente se elas estiverem sempre no mesmo destino.
Peter Cordes,
Isso cai na armadilha comum dos microbenchmarks: parece rápido quando os preditores de ramificação são importantes e nada mais está acontecendo. A sobrecarga de previsão incorreta é maior para indireta do callque direta call. (E sim, as callinstruções normais também precisam de previsão. O estágio de busca precisa saber o próximo endereço a buscar antes que este bloco seja decodificado, então ele tem que prever o próximo bloco de busca com base no endereço do bloco atual, ao invés do endereço da instrução. como prever onde neste bloco há uma instrução de ramificação ...)
Peter Cordes,