In C ++ perché e come rallentano le funzioni virtuali?


38

Qualcuno può spiegare in dettaglio, come funziona esattamente la tabella virtuale e quali puntatori sono associati quando vengono chiamate funzioni virtuali.

Se sono effettivamente più lenti, puoi mostrare che il tempo impiegato dalla funzione virtuale per eseguire è più dei normali metodi di classe? È facile perdere traccia di come / cosa sta accadendo senza vedere un po 'di codice.


5
Cercare la corretta chiamata del metodo da una vtable richiederà ovviamente più tempo che chiamare direttamente il metodo, poiché c'è ancora molto da fare. Quanto più a lungo, o se quel tempo aggiuntivo è significativo nel contesto del proprio programma, è un'altra domanda. en.wikipedia.org/wiki/Virtual_method_table
Robert Harvey

10
Più lento di cosa esattamente? Ho visto un codice che aveva un'implementazione lenta e rotta del comportamento dinamico con molte istruzioni switch solo perché alcuni programmatori avevano sentito che le funzioni virtuali erano lente.
Christopher Creutzig,

7
Spesso, non è che le chiamate virtuali stesse siano lente, ma che il compilatore non abbia la possibilità di incorporarle.
Kevin Hsu,

4
@Kevin Hsu: sì, questo è assolutamente. Quasi ogni volta che qualcuno ti dice che è stato accelerato dall'eliminazione di alcune "spese generali di chiamata di funzione virtuale", se lo guardi da dove proviene effettivamente tutto lo speedup provengono da ottimizzazioni che ora sono possibili perché il compilatore non è in grado di ottimizzare la chiamata indeterminata in precedenza.
martedì

7
Persino una persona in grado di leggere il codice assembly non può prevedere con precisione il suo overhead nell'esecuzione effettiva della CPU. I produttori di CPU desktop hanno investito in decenni di ricerca non solo per la previsione delle filiali, ma anche per la stima del valore e l'esecuzione speculativa per la ragione principale di mascherare la latenza delle funzioni virtuali. Perché? Perché i sistemi operativi desktop e il software li usano molto. (Non direi lo stesso delle CPU mobili.)
Rwong

Risposte:


55

I metodi virtuali sono comunemente implementati tramite le cosiddette tabelle dei metodi virtuali (vtable in breve), in cui sono memorizzati i puntatori a funzione. Questo aggiunge un riferimento indiretto alla chiamata effettiva (devo recuperare l'indirizzo della funzione da chiamare dalla vtable, quindi chiamarla - invece di chiamarla subito). Ovviamente, ci vuole un po 'di tempo e un po' più di codice.

Tuttavia, non è necessariamente la causa principale della lentezza. Il vero problema è che il compilatore (generalmente / di solito) non può sapere quale funzione verrà chiamata. Quindi non può incorporarlo o eseguire altre ottimizzazioni del genere. Questo da solo potrebbe aggiungere una dozzina di istruzioni inutili (preparare registri, chiamare, quindi ripristinare lo stato in seguito) e potrebbe inibire altre ottimizzazioni apparentemente non correlate. Inoltre, se ti ramifichi come un matto chiamando molte implementazioni diverse, subisci gli stessi colpi che potresti subire ramificando come un matto con altri mezzi: il predittore di cache e branch non ti aiuterà, i rami impiegheranno più tempo di un perfettamente prevedibile ramo.

Grande ma : questi successi di solito sono troppo piccoli per essere importanti. Vale la pena considerare se si desidera creare un codice ad alte prestazioni e considerare l'aggiunta di una funzione virtuale che verrebbe chiamata a una frequenza allarmante. Tuttavia, anche tenere presente che la sostituzione di chiamate di funzione virtuale con altri mezzi di ramificazione ( if .. else, switch, puntatori a funzione, ecc) non risolve il problema fondamentale - può benissimo essere più lenta. Il problema (se esiste) non sono le funzioni virtuali ma l'indirizzamento (non necessario).

Modifica: la differenza nelle istruzioni di chiamata è descritta in altre risposte. Fondamentalmente, il codice per una chiamata statica ("normale") è:

  • Copia alcuni registri nello stack, per consentire alla funzione chiamata di utilizzare quei registri.
  • Copia gli argomenti in posizioni predefinite, in modo che la funzione chiamata possa trovarli indipendentemente da dove viene chiamata.
  • Inserisci l'indirizzo di ritorno.
  • Diramazione / salto al codice della funzione, che è un indirizzo in fase di compilazione e quindi codificato nel file binario dal compilatore / linker.
  • Ottieni il valore di ritorno da una posizione predefinita e ripristina i registri che vogliamo utilizzare.

Una chiamata virtuale fa esattamente la stessa cosa, tranne per il fatto che l'indirizzo della funzione non è noto al momento della compilazione. Invece, un paio di istruzioni ...

  • Ottieni il puntatore vtable, che punta a una matrice di puntatori di funzione (indirizzi di funzione), uno per ogni funzione virtuale, dall'oggetto.
  • Ottieni l'indirizzo della funzione corretta dalla vtable in un registro (l'indice in cui è memorizzato l'indirizzo della funzione corretta viene deciso al momento della compilazione).
  • Passa all'indirizzo nel registro, anziché passare a un indirizzo hardcoded.

Per quanto riguarda i rami: un ramo è tutto ciò che salta a un'altra istruzione invece di lasciare semplicemente l'esecuzione dell'istruzione successiva. Questo include if, switchparti di vari loop, chiamate di funzione, ecc. E talvolta il compilatore implementa cose che non sembrano ramificarsi in un modo che ha effettivamente bisogno di un ramo sotto il cofano. Vedere Perché l'elaborazione di una matrice ordinata è più veloce di una matrice non ordinata? per quale motivo potrebbe essere lento, cosa fanno le CPU per contrastare questo rallentamento e come questo non sia un rimedio.


6
@ JörgWMittag sono tutte cose da interprete e sono ancora più lente del codice binario generato dai compilatori C ++
Sam

13
@ JörgWMittag Queste ottimizzazioni esistono principalmente per rendere la indiretta / associazione in ritardo (quasi) libera quando non è necessaria , perché in quelle lingue ogni chiamata è tecnicamente in ritardo. Se chiami davvero molti metodi virtuali diversi da un posto per un breve periodo, queste ottimizzazioni non aiutano o danneggiano attivamente (crea un sacco di codice per nulla). I ragazzi del C ++ non sono molto interessati a queste ottimizzazioni perché si trovano in una situazione molto diversa ...

10
@ JörgWMittag ... I ragazzi del C ++ non sono molto interessati a queste ottimizzazioni perché si trovano in una situazione molto diversa: il modo vtable compilato AOT è già piuttosto veloce, pochissime chiamate sono in realtà virtuali, molti casi di polimorfismo sono precoci- associato (tramite modelli) e quindi modificabile all'ottimizzazione AOT. Infine, fare queste ottimizzazioni in modo adattivo (anziché limitarsi a speculare in fase di compilazione) richiede la generazione di codice di runtime, che introduce tonnellate di mal di testa. I compilatori JIT hanno già risolto questi problemi per altri motivi, quindi a loro non importa, ma i compilatori AOT vogliono evitarlo.

3
ottima risposta, +1. Una cosa da notare è che a volte i risultati della ramificazione sono noti al momento della compilazione, ad esempio quando si scrivono classi di framework che devono supportare usi diversi ma una volta che il codice dell'applicazione interagisce con quelle classi, l'uso specifico è già noto. In questo caso, l'alternativa alle funzioni virtuali, potrebbero essere i modelli C ++. Un buon esempio potrebbe essere il CRTP, che emula il comportamento della funzione virtuale senza vtables: en.wikipedia.org/wiki/Curiously_recurring_template_pattern
DXM

3
@James Hai ragione. Quello che ho cercato di dire è: qualsiasi indiretto ha gli stessi problemi, non è nulla di specifico virtual.

23

Ecco un po 'di codice effettivamente smontato da una chiamata di funzione virtuale e una chiamata non virtuale, rispettivamente:

mov    -0x8(%rbp),%rax
mov    (%rax),%rax
mov    (%rax),%rax
callq  *%rax

callq  0x4007aa

Puoi vedere che la chiamata virtuale richiede tre istruzioni aggiuntive per cercare l'indirizzo corretto, mentre l'indirizzo della chiamata non virtuale può essere compilato.

Tuttavia, si noti che la maggior parte delle volte quel tempo di ricerca extra può essere considerato trascurabile. In situazioni in cui il tempo di ricerca sarebbe significativo, come in un ciclo, il valore può di solito essere memorizzato nella cache eseguendo le prime tre istruzioni prima del ciclo.

L'altra situazione in cui il tempo di ricerca diventa significativo è se si dispone di una raccolta di oggetti e si esegue il looping chiamando una funzione virtuale su ciascuno di essi. Tuttavia, in tal caso, avrai bisogno di alcuni mezzi per selezionare quale funzione chiamare comunque, e una ricerca di tabella virtuale è un mezzo valido come un altro. Infatti, poiché il codice di ricerca vtable è così ampiamente utilizzato, è fortemente ottimizzato, quindi provare a aggirarlo manualmente ha buone probabilità di provocare prestazioni peggiori .


1
La cosa da capire è che la ricerca vtable e la chiamata indiretta avranno in quasi tutti i casi un impatto trascurabile sul tempo totale di esecuzione del metodo chiamato.
John R. Strohm,

12
@ JohnR.Strohm Il trascurabile di un uomo è il collo di bottiglia di un altro uomo
James

1
-0x8(%rbp). oh mio ... quella sintassi AT&T.
Abyx,

" tre istruzioni aggiuntive " no, solo due: caricamento di vptr e caricamento del puntatore a funzione
curiousguy,

@curiousguy sono in realtà tre istruzioni aggiuntive. Hai dimenticato che un metodo virtuale viene sempre chiamato su un puntatore , quindi devi prima caricare il puntatore in un registro. Per riassumere, il primo passo è caricare l'indirizzo che la variabile puntatore contiene nel registro% rax, quindi in base all'indirizzo nel registro, caricare il vtpr su questo indirizzo per registrare% rax, quindi secondo questo indirizzo nel registrati, carica l'indirizzo del metodo da chiamare in% rax, quindi callq *% rax !.
Gab 是 好人

18

Più lento di cosa ?

Le funzioni virtuali risolvono un problema che non può essere risolto con chiamate di funzione dirette. In generale, puoi confrontare solo due programmi che calcolano la stessa cosa. "Questo ray tracciante è più veloce di quel compilatore" non ha senso e questo principio si generalizza anche a piccole cose come singole funzioni o costrutti del linguaggio di programmazione.

Se non usi una funzione virtuale per passare dinamicamente a un pezzo di codice basato su un dato, come il tipo di un oggetto, allora dovrai usare qualcos'altro, come switchun'istruzione per realizzare la stessa cosa. Quel qualcos'altro ha i suoi costi generali, oltre alle implicazioni sull'organizzazione del programma che influenzano la sua manutenibilità e le prestazioni globali.

Si noti che in C ++, le chiamate a funzioni virtuali non sono sempre dinamiche. Quando vengono effettuate chiamate su un oggetto il cui tipo esatto è noto (perché l'oggetto non è un puntatore o riferimento o perché il suo tipo può essere inferito staticamente in altro modo), le chiamate sono solo normali chiamate di funzioni membro. Ciò non significa solo che non ci sono spese generali di spedizione, ma anche che queste chiamate possono essere integrate allo stesso modo delle chiamate ordinarie.

In altre parole, il compilatore C ++ può funzionare quando le funzioni virtuali non richiedono l'invio virtuale, quindi di solito non c'è motivo di preoccuparsi delle loro prestazioni rispetto alle funzioni non virtuali.

Novità: Inoltre, non dobbiamo dimenticare le librerie condivise. Se stai usando una classe che si trova in una libreria condivisa, la chiamata a una normale funzione membro non sarà semplicemente una bella sequenza di istruzioni simile callq 0x4007aa. Deve passare attraverso alcuni cerchi, come indirizzare indirettamente attraverso una "tabella di collegamento del programma" o una tale struttura. Pertanto, l'indirizzamento della libreria condivisa potrebbe in qualche modo (se non completamente) livellare la differenza di costo tra una chiamata virtuale (veramente indiretta) e una chiamata diretta. Pertanto, il ragionamento sui compromessi delle funzioni virtuali deve tenere conto del modo in cui è costruito il programma: se la classe dell'oggetto target è monoliticamente collegata al programma che sta effettuando la chiamata.


4
"Più lento di cosa?" - se rendi virtuale un metodo che non deve essere, hai abbastanza materiale di confronto.
martedì

2
Grazie per aver sottolineato che le chiamate alle funzioni virtuali non sono sempre dinamiche. Ogni altra risposta qui fa sembrare che dichiarare una funzione virtuale significhi un hit automatico delle prestazioni, indipendentemente dalle circostanze.
Syndog,

12

perché una chiamata virtuale è equivalente a

res_t (*foo)(arg_t);
foo = (obj->vtable[foo_offset]);
foo(obj,args)

dove con una funzione non virtuale il compilatore può piegare costantemente la prima riga, questa è una dereferenza un'aggiunta e una chiamata dinamica trasformata in una semplice chiamata statica

questo consente anche di incorporare la funzione (con tutte le dovute conseguenze di ottimizzazione)

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.