Domanda

avente almeno un metodo virtuale in una classe C ++ (o una delle sue classi padre), significa che la classe avrà una tabella virtuale, e ogni istanza avrà un puntatore virtuale.

Quindi, il costo della memoria è abbastanza chiaro. La più importante è il costo memoria istanze (soprattutto se i casi sono piccole, ad esempio se sono solo scopo di contenere un numero intero. In questo caso avere un puntatore virtuale in ogni caso potrebbe raddoppiare le dimensioni delle istanze Quanto lo spazio di memoria utilizzata dalle tabelle virtuali, immagino solito è trascurabile rispetto allo spazio utilizzato dal codice metodo effettivo.

Questo mi porta alla mia domanda: c'è un costo delle prestazioni misurabili (vale a dire l'impatto della velocità) per la realizzazione di un metodo virtuale? Ci sarà una ricerca nella tabella virtuale in fase di esecuzione, ad ogni chiamata di metodo, quindi se ci sono chiamate molto frequenti a questo metodo, e se questo metodo è molto breve, quindi ci potrebbe essere un calo di prestazioni misurabili? Immagino che dipende dalla piattaforma, ma qualcuno ha eseguito alcuni benchmark?

Il motivo che chiedo è che mi sono imbattuto in un bug che è accaduto a essere a causa di un programmatore dimenticare di definire un metodo virtuale. Questa non è la prima volta che vedo questo tipo di errore. E ho pensato: perché abbiamo Aggiungi la parola chiave virtuale quando necessario, invece di rimozione la parola chiave virtuale quando siamo assolutamente sicuri che si tratta di non necessarie ? Se il costo delle prestazioni è basso, penso che semplicemente consigliare il seguente nella mia squadra: è sufficiente fare tutti metodo virtuale di default, tra cui il distruttore, in ogni classe, e rimuovere solo quando è necessario . Suona pazzesco per voi?

È stato utile?

Soluzione

I corse alcuni tempi su una 3GHz in- ordinare processore PowerPC. Su tale architettura, una chiamata di funzione virtuale costa 7 nanosecondi più di una chiamata di funzione (non virtuale) diretto.

Quindi, in realtà non vale la pena preoccuparsi il costo a meno che la funzione è qualcosa come un banale Get () / Set () di accesso, in cui qualcosa di diverso in linea è una specie di spreco. Un overhead 7ns su una funzione che Inlines a 0.5ns è grave; un overhead 7ns su una funzione che prende 500ms da eseguire è insignificante.

La grande costo di funzioni virtuali non è proprio la ricerca di un puntatore a funzione nel vtable (che di solito è solo un singolo ciclo), ma che il salto indiretta di solito non può essere ramo-previsto. Ciò può causare una grande bolla gasdotto come il processore non può recuperare tutte le istruzioni fino a quando il salto indiretta (la chiamata attraverso il puntatore a funzione) si è ritirato e un nuovo puntatore all'istruzione computerizzata. Così, il costo di una chiamata di funzione virtuale è molto più grande di quanto potrebbe sembrare dal guardare l'assemblea ... ma ancora solo 7 nanosecondi.

Modifica Andrew, Non saprei, e anche gli altri alzano il punto molto buona che una chiamata di funzione virtuale può causare un cache miss istruzioni: se si salta ad un indirizzo di codice che non è nella cache, allora l'intero programma si ferma morti, mentre le istruzioni sono recuperati dalla memoria principale. Si tratta di sempre una bancarella significativo: su Xenon, circa 650 cicli (per i miei test).

Tuttavia questo non è un problema specifico a funzioni virtuali perché anche una chiamata di funzione diretta causerà una miss se si salta a istruzioni che non sono nella cache. Ciò che conta è se la funzione è stato eseguito prima di recente (il che rende più probabile che sia in cache), e se la vostra architettura in grado di prevedere i rami statici (non virtuali) a prendere tali istruzioni nella cache prima del tempo. La mia PPC non lo fa, ma forse più recente hardware di Intel fa.

I miei tempi di controllo per l'influenza di miss iCache sull'esecuzione (deliberatamente, dal momento che stavo cercando di esaminare il gasdotto CPU in isolamento), in modo da sconto che costi.

Altri suggerimenti

C'è sicuramente in testa misurabile quando si chiama una funzione virtuale - la chiamata deve utilizzare vtable per risolvere l'indirizzo della funzione per quel tipo di oggetto. Le istruzioni supplementari sono l'ultima delle vostre preoccupazioni. Non solo VTables prevenire molti potenziali ottimizzazioni del compilatore (dal momento che il tipo è polimorfico il compilatore) possono anche thrash vostro I-Cache.

Naturalmente se queste sanzioni sono significative o meno dipende dalla vostra applicazione, come spesso vengono eseguiti questi percorsi di codice, e il vostro modelli di eredità.

A mio parere, però, avere tutto come virtuale di default è una soluzione coperta per un problema si potrebbe risolvere in altri modi.

Forse si potrebbe guardare a come le classi sono progettati / documentate / scritta. Generalmente l'intestazione di una classe dovrebbe rendere chiaro quali funzioni possono essere sostituite da classi derivate e come vengono chiamati. I programmatori hanno scrivere questa documentazione è disponibile nel garantire che sono contrassegnati correttamente come virtuale.

Vorrei anche dire che dichiarando ogni funzione come virtuale potrebbe portare a più bug che solo dimenticare di contrassegnare qualcosa come virtuale. Se tutte le funzioni sono tutto virtuale può essere sostituito da classi base - pubblico, protetto, privato - tutto diventa gioco giusto. Per caso o l'intenzione sottoclassi potrebbe quindi modificare il comportamento di funzioni che poi causano problemi quando impiegati per l'attuazione di base.

Dipende. :) (aveva vi aspettavate altro?)

Una volta che una classe ha una funzione virtuale, esso non può più essere un tipo di dati POD, (ma non può essere stato uno prima sia, nel qual caso questo non farà una differenza) e che rende tutta una serie di ottimizzazioni impossibili .

std :: copy () sui tipi di POD pianura può ricorrere ad una semplice routine memcpy, ma i tipi non-POD devono essere gestite con più attenzione.

Edilizia diventa molto più lento, perché il vtable deve essere inizializzato. Nel peggiore dei casi, la differenza di prestazioni tra il POD e tipi di dati non-POD può essere significativo.

Nel peggiore dei casi, è possibile vedere 5x esecuzione più lenta (tale numero è tratto da un progetto universitario che ho fatto di recente per reimplementare alcune classi della libreria standard. Il nostro contenitore voluti circa 5 volte più a lungo per costruire non appena il tipo di dati che memorizzata ottenuto un vtable)

Naturalmente, nella maggior parte dei casi, è improbabile vedere alcuna differenza di prestazioni misurabili, questo è semplicemente far notare che in alcuni casi di confine, può essere costoso.

Tuttavia, le prestazioni non dovrebbe essere la vostra considerazione primaria qui. Fare tutto virtuale non è una soluzione perfetta per altri motivi.

tutto Permettere ad essere sottoposto a override in classi derivate rende molto più difficile da mantenere invarianti di classe. Come funziona la garanzia classe che rimane in uno stato coerente quando uno qualsiasi dei suoi metodi potrebbero essere ridefinito in qualsiasi momento?

Rendete tutto virtuale può eliminare un paio di potenziali bug, ma introduce anche nuovi.

Se avete bisogno di funzionalità di spedizione virtuale, si deve pagare il prezzo. Il vantaggio di C ++ è che si può utilizzare una efficace attuazione di spedizione virtuale fornito dal compilatore, piuttosto che una versione possibilmente inefficiente di implementare da soli.

Tuttavia, pesantemente te stesso con la testa se non needx sta forse andando un po 'troppo lontano. E la maggior parte non classesare progettati per essere ereditato da -. Per creare una buona classe di base richiede più di fare le sue funzioni virtuale

la spedizione virtuale è un ordine di grandezza più lento di alcune alternative - non a causa di indirezione così tanto come la prevenzione di inline. Qui di seguito, illustro che contrastando la spedizione virtuale con un'implementazione incorporando un "tipo (-Individuare) numero" negli oggetti e l'utilizzo di un'istruzione switch per selezionare il codice specifico tipo. Questo evita la funzione di chiamata in testa completamente - solo facendo un salto locale. C'è un costo potenziale di manutenibilità, dipendenze ricompilazione ecc attraverso la localizzazione forzata (nello switch) della funzionalità specifiche del tipo.


ATTUAZIONE

#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';
  }
}

dei risultati delle prestazioni

Sul mio 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

Questo suggerisce un approccio linea tipo numero commutazione è circa (1,28 - 0.23) / (0,344 - 0.23) = 9.2 volte più veloce. Naturalmente, questo è specifico per il sistema esatto testato / compilatore bandiere e versione e così via, ma in generale indicativa.


COMMENTI RE SPEDIZIONE VIRTUALE

Va detto però che le spese generali chiamata di funzione virtuale sono qualcosa che è raramente significativo, e solo per oft-funzioni chiamate banali (come getter e setter). Anche allora, si potrebbe essere in grado di fornire una singola funzione per ottenere e impostare un sacco di cose in una volta, riducendo al minimo il costo. La gente si preoccupa modo spedizione virtuale troppo - così fanno fare il profiling prima di trovare alternative scomode. Il problema principale con loro è che essi svolgono una funzione di chiamata di out-of-line, anche se anche delocalizzano il codice eseguito che cambia i modelli di utilizzo della cache (in meglio o (più spesso) di peggio).

Il costo aggiuntivo è praticamente nulla nella maggior parte degli scenari. (Scusate il gioco di parole). ejac ha già postato relative misure ragionevoli.

La cosa più importante si rinuncia è ottimizzazioni possibili a causa di inline. Possono essere particolarmente utile se la funzione è chiamata a parametri costanti. Questo rende raramente una reale differenza, ma in alcuni casi, questo può essere enorme.


Per quanto riguarda le ottimizzazioni:
E 'importante conoscere e considerare il costo relativo dei costrutti di tua lingua. Notazione O-grande è onl metà della storia - come fa la vostra scala di applicazione . L'altra metà è il fattore costante di fronte ad esso.

Come regola generale, non voglio andare fuori dal mio modo per evitare funzioni virtuali, a meno che non vi siano indicazioni chiare e specifiche che si tratta di un collo di bottiglia. Un design pulito viene sempre prima - ma è solo una delle parti interessate che dovrebbe non indebitamente ferire gli altri.


Escogitato Esempio: Un distruttore virtuale vuoto su una matrice di un milione di piccoli elementi può arare attraverso almeno 4MB di dati, la cache thrashing. Se quel distruttore può essere inline via, non saranno toccati i dati.

Quando si scrive codice della libreria, tali considerazioni sono tutt'altro che prematuro. Non si sa mai quanti cicli sarà messo in giro la vostra funzione.

Mentre tutti gli altri è corretta circa le prestazioni dei metodi virtuali e così via, penso che il vero problema è se la squadra conosce la definizione della parola chiave virtuale in C ++.

Si consideri questo codice, che cosa è l'uscita?

#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;
}

nulla di sorprendente qui:

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

Come nulla è virtuale. Se la parola chiave virtuale viene aggiunta alla parte anteriore del Foo in entrambe le classi A e B, otteniamo questo per l'uscita:

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

Più o meno quello che tutti si aspettano.

Ora, lei ha detto che ci sono bug perché qualcuno ha dimenticato di aggiungere una parola chiave virtuale. Quindi prendere in considerazione questo codice (in cui viene aggiunta la parola chiave virtuale per A, ma non di classe B). Qual è la produzione, allora?

#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;
}

Risposta: lo stesso come se si aggiunge la parola chiave virtual a B? La ragione è che la firma per B :: Foo corrisponde esattamente come A :: Foo () e perché di un Foo è virtuale, quindi è B.

Consideriamo ora il caso in cui del B Foo è virtuale e A di non lo è. Qual è l'uscita, allora? In questo caso, l'uscita è

<*>

La parola chiave virtuale funziona verso il basso nella gerarchia, non verso l'alto. E non fa mai i metodi della classe base virtuale. La prima volta che si incontra un metodo virtuale nella gerarchia è quando inizia il polimorfismo. Non c'è un modo per le classi successive per rendere le classi precedenti hanno metodi virtuali.

Non dimenticate che i metodi virtuali significa che questa classe sta dando le classi future la possibilità di modificare / cambiare alcuni dei suoi comportamenti.

Quindi, se si dispone di una regola per rimuovere la parola chiave virtuale, non può avere l'effetto desiderato.

La parola chiave virtuale in C ++ è un concetto potente. È necessario assicurarsi che ogni membro del team sa veramente questo concetto in modo che possa essere utilizzato come progettato.

A seconda della piattaforma, il sovraccarico di una chiamata virtuale può essere molto indesiderabile. Dichiarando ogni funzione virtuale si sta essenzialmente li chiama tutti attraverso un puntatore a funzione. Per lo meno questo è un dereferenziare in più, ma su alcune piattaforme PPC userà microprogramma o altrimenti istruzioni lento per raggiungere questo obiettivo.

Mi consiglia contro il vostro suggerimento per questo motivo, ma se ti aiuta a prevenire i bug allora può valere la pena il compromesso. Non posso fare a meno di pensare che ci deve essere qualche via di mezzo che vale la pena di trovare, però.

Si richiede solo un paio di istruzioni asm in più per chiamare il metodo virtuale.

Ma io non credo che si preoccupi che il divertimento (int a, int b) ha un paio di istruzioni in più 'spinta' rispetto al divertimento (). Quindi non preoccupatevi troppo virtuali, fino a quando si è in situazione particolare e vedere che conduce realmente a problemi.

P.S. Se si dispone di un metodo virtuale, assicuratevi di avere un distruttore virtuale. In questo modo si evitano eventuali problemi


In risposta alla 'xtofl' e commenti 'Tom'. Ho fatto piccoli test con 3 funzioni:

  1. Virtual
  2. Normal
  3. Normal con i parametri 3 int

Il mio test è stato un semplice iterazione:

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

Ed ecco i risultati:

  1. 3.913 sec
  2. 3.873 sec
  3. 3.970 sec

E 'stato compilato da VC ++ in modalità debug. Ho fatto solo 5 prove per metodo e calcolato il valore medio (per cui i risultati potrebbero essere piuttosto imprecisi) ... Qualsiasi modo, i valori sono quasi uguali assumendo 100 milioni di chiamate. E il metodo con 3 spinta in più / pop è stata più lenta.

Il punto principale è che se non ti piace l'analogia con la spinta / pop, pensare di più if / else nel codice? Avete in mente pipeline di CPU quando si aggiunge in più if / else ;-) Inoltre, non si sa mai su ciò che il codice della CPU sarà in esecuzione ... compilatore abituale può genera il codice più ottimale per una CPU e meno ottimale per un altro (< a href = "http://en.wikipedia.org/wiki/Intel_C%2B%2B_Compiler" rel = "nofollow noreferrer"> Intel C ++ Compiler )

Autorizzato sotto: CC-BY-SA insieme a attribuzione
Non affiliato a StackOverflow
scroll top