Volevo saltare qui tra queste risposte già eccellenti e ammettere che ho adottato il brutto approccio di lavorare effettivamente all'indietro contro l'antimodifica di cambiare il codice polimorfico in switches
o if/else
rami con guadagni misurati. Ma non l'ho fatto all'ingrosso, solo per i percorsi più critici. Non deve essere così in bianco e nero.
Come disclaimer, lavoro in settori come il raytracing in cui la correttezza non è così difficile da raggiungere (ed è spesso sfocata e approssimata comunque) mentre la velocità è spesso una delle qualità più competitive ricercate. Una riduzione dei tempi di rendering è spesso una delle richieste degli utenti più comuni, con noi che ci grattiamo costantemente la testa e capiamo come raggiungerlo per i percorsi misurati più critici.
Refactoring polimorfico di condizionali
In primo luogo, vale la pena capire perché il polimorfismo può essere preferibile da un aspetto di manutenibilità rispetto alla ramificazione condizionale ( switch
o un mucchio di if/else
affermazioni). Il vantaggio principale qui è l' estensibilità .
Con il codice polimorfico, possiamo introdurre un nuovo sottotipo nella nostra base di codice, aggiungerne istanze ad una struttura di dati polimorfici e fare in modo che tutto il codice polimorfico esistente funzioni ancora automagicamente senza ulteriori modifiche. Se hai un sacco di codice sparso in una grande base di codice che ricorda la forma di "Se questo tipo è" pippo " , potresti trovarti con un orribile onere di aggiornare 50 diverse sezioni di codice per introdurre un nuovo tipo di cose, e alla fine ne mancano ancora alcune.
I vantaggi di manutenibilità del polimorfismo naturalmente diminuiscono qui se hai solo una coppia o anche una sezione della tua base di codice che deve eseguire tali controlli di tipo.
Barriera di ottimizzazione
Suggerirei di non guardare questo dal punto di vista della ramificazione e del pipelining, e di guardarlo di più dalla mentalità progettuale del compilatore delle barriere di ottimizzazione. Esistono modi per migliorare la previsione del ramo che si applicano ad entrambi i casi, come l'ordinamento dei dati in base al sottotipo (se si adatta a una sequenza).
Ciò che differisce di più tra queste due strategie è la quantità di informazioni che l'ottimizzatore ha in anticipo. Una chiamata di funzione nota fornisce molte più informazioni, una chiamata di funzione indiretta che chiama una funzione sconosciuta in fase di compilazione porta a una barriera di ottimizzazione.
Quando la funzione chiamata è nota, i compilatori possono cancellare la struttura e ridurla in pezzi, allineare le chiamate, eliminare il potenziale aliasing ambientale, fare un lavoro migliore nell'assegnazione istruzione / registro, possibilmente persino riorganizzare loop e altre forme di rami, generando hard LUT in miniatura codificati, se del caso (qualcosa che GCC 5.3 mi ha recentemente sorpreso con una switch
dichiarazione utilizzando un LUT di dati codificato per i risultati anziché una tabella di salto).
Alcuni di questi vantaggi si perdono quando iniziamo a introdurre nel mix incognite in fase di compilazione, come nel caso di una chiamata di funzione indiretta, ed è qui che molto probabilmente la ramificazione condizionale può offrire un vantaggio.
Ottimizzazione della memoria
Prendi un esempio di un videogioco che consiste nell'elaborare ripetutamente una sequenza di creature in un ciclo stretto. In tal caso, potremmo avere un contenitore polimorfico come questo:
vector<Creature*> creatures;
Nota: per semplicità ho evitato unique_ptr
qui.
... dov'è Creature
un tipo di base polimorfica. In questo caso, una delle difficoltà con i contenitori polimorfici è che spesso vogliono allocare memoria per ciascun sottotipo separatamente / individualmente (es: usando il lancio predefinito operator new
per ogni singola creatura).
Ciò renderà spesso la prima prioritizzazione per l'ottimizzazione (dovremmo averne bisogno) basata sulla memoria piuttosto che sulla ramificazione. Una strategia qui è quella di utilizzare un allocatore fisso per ciascun sottotipo, incoraggiando una rappresentazione contigua allocando in grossi blocchi e raggruppando la memoria per ciascun sottotipo allocato. Con una tale strategia, può sicuramente aiutare a ordinare questo creatures
contenitore per sottotipo (e per indirizzo), poiché ciò non solo migliora la previsione del ramo ma migliora anche la località di riferimento (consentendo l'accesso a più creature dello stesso sottotipo da una singola riga della cache prima dello sfratto).
Devirtualizzazione parziale di strutture dati e loop
Diciamo che hai seguito tutti questi movimenti e desideri ancora più velocità. Vale la pena notare che ogni passaggio che intraprendiamo qui è degradabilità della manutenibilità e saremo già in una fase di macinazione dei metalli con rendimenti delle prestazioni decrescenti. Quindi, ci deve essere una domanda di prestazioni piuttosto significativa se ci addentriamo in questo territorio, dove siamo disposti a sacrificare ulteriormente la manutenibilità per ottenere sempre più piccoli miglioramenti delle prestazioni.
Tuttavia, il prossimo passo da provare (e sempre con la volontà di annullare i nostri cambiamenti se non aiuta affatto) potrebbe essere la devirtualizzazione manuale.
Suggerimento per il controllo della versione: a meno che tu non sia molto più esperto di ottimizzazione di me, può valere la pena creare un nuovo ramo a questo punto con la volontà di buttarlo via se i nostri sforzi di ottimizzazione mancano, cosa che potrebbe benissimo accadere. Per me è tutto prova ed errore dopo questo tipo di punti, anche con un profiler in mano.
Tuttavia, non dobbiamo applicare questa mentalità all'ingrosso. Continuando il nostro esempio, supponiamo che questo videogioco sia composto principalmente da creature umane, di gran lunga. In tal caso, possiamo devirtualizzare solo le creature umane sollevandole e creando una struttura dati separata solo per loro.
vector<Human> humans; // common case
vector<Creature*> other_creatures; // additional rare-case creatures
Ciò implica che tutte le aree della nostra base di codice che devono elaborare le creature necessitano di un caso separato per le creature umane. Tuttavia, ciò elimina il sovraccarico dinamico della spedizione (o forse, più appropriatamente, la barriera di ottimizzazione) per gli umani che sono, di gran lunga, il tipo di creatura più comune. Se queste aree sono numerose e possiamo permettercelo, potremmo farlo:
vector<Human> humans; // common case
vector<Creature*> other_creatures; // additional rare-case creatures
vector<Creature*> creatures; // contains humans and other creatures
... se possiamo permettercelo, i percorsi meno critici possono rimanere come sono e semplicemente elaborare tutti i tipi di creatura in modo astratto. I percorsi critici possono essere elaborati humans
in un loop e other_creatures
in un secondo loop.
Possiamo estendere questa strategia secondo necessità e potenzialmente spremere alcuni guadagni in questo modo, ma vale la pena notare quanto stiamo degradando la manutenibilità nel processo. L'uso dei modelli di funzione qui può aiutare a generare il codice sia per gli esseri umani che per le creature senza duplicare manualmente la logica.
Devirtualizzazione parziale delle classi
Qualcosa che ho fatto anni fa, che era davvero disgustoso, e non sono nemmeno sicuro che sia più benefico (era nell'era C ++ 03), era la parziale devirtualizzazione di una classe. In quel caso, stavamo già memorizzando un ID di classe con ciascuna istanza per altri scopi (accessibile tramite un accessor nella classe di base che non era virtuale). Lì abbiamo fatto qualcosa di analogo a questo (la mia memoria è un po 'confusa):
switch (obj->type())
{
case id_common_type:
static_cast<CommonType*>(obj)->non_virtual_do_something();
break;
...
default:
obj->virtual_do_something();
break;
}
... dove è virtual_do_something
stato implementato per chiamare versioni non virtuali in una sottoclasse. È grave, lo so, fare un downcast statico esplicito per devirtualizzare una chiamata di funzione. Non ho idea di quanto sia vantaggioso adesso poiché non provo questo tipo di cose da anni. Con un'esposizione alla progettazione orientata ai dati, ho trovato la strategia di cui sopra di suddividere strutture di dati e loop in modo caldo / freddo per essere molto più utile, aprendo più porte per strategie di ottimizzazione (e molto meno brutte).
Devirtualizzazione all'ingrosso
Devo ammettere che non sono mai arrivato così lontano applicando una mentalità di ottimizzazione, quindi non ho idea dei vantaggi. Ho evitato le funzioni indirette in previsione nei casi in cui sapevo che ci sarebbe stato solo un insieme centrale di condizionali (es: elaborazione di eventi con un solo luogo centrale di elaborazione di eventi), ma non ho mai iniziato con una mentalità polimorfica e ottimizzato fino in fondo fino a qui.
Teoricamente, i vantaggi immediati qui potrebbero essere un modo potenzialmente più piccolo di identificare un tipo rispetto a un puntatore virtuale (es: un singolo byte se puoi impegnarti all'idea che ci siano 256 tipi univoci o meno) oltre a cancellare completamente queste barriere di ottimizzazione .
In alcuni casi potrebbe anche essere utile scrivere codice più semplice da mantenere (rispetto agli esempi di devirtualizzazione manuale ottimizzati sopra) se si utilizza semplicemente switch
un'istruzione centrale senza dover suddividere le strutture di dati e i cicli in base al sottotipo o se esiste un ordine -dipendenza in questi casi in cui le cose devono essere elaborate in un ordine preciso (anche se ciò ci fa ramificare in tutto il luogo). Questo sarebbe per i casi in cui non hai troppi posti che devono fare il switch
.
In genere non lo consiglierei nemmeno con una mentalità molto critica in termini di prestazioni a meno che ciò non sia ragionevolmente facile da mantenere. "Facile da mantenere" tenderebbe a dipendere da due fattori dominanti:
- Non avere un reale bisogno di estensibilità (es: sapere con certezza che hai esattamente 8 tipi di cose da elaborare, e mai più).
- Non ci sono molti posti nel tuo codice che devono controllare questi tipi (es: un posto centrale).
... tuttavia, nella maggior parte dei casi, raccomando lo scenario di cui sopra e si procede ripetutamente verso soluzioni più efficienti mediante parziale devirtualizzazione, se necessario. Ti dà molto più respiro per bilanciare le esigenze di estensibilità e manutenibilità con le prestazioni.
Funzioni virtuali e puntatori di funzioni
Per finire, ho notato qui che c'erano delle discussioni sulle funzioni virtuali rispetto ai puntatori di funzione. È vero che le funzioni virtuali richiedono un po 'di lavoro extra da chiamare, ma ciò non significa che siano più lente. Contro-intuitivamente, potrebbe persino renderli più veloci.
È controintuitivo qui perché siamo abituati a misurare i costi in termini di istruzioni senza prestare attenzione alle dinamiche della gerarchia di memoria che tendono ad avere un impatto molto più significativo.
Se stiamo confrontando a class
con 20 funzioni virtuali con a struct
che memorizza 20 puntatori a funzione ed entrambi sono istanziati più volte, l'overhead di memoria di ciascuna class
istanza in questo caso 8 byte per il puntatore virtuale su macchine a 64 bit, mentre la memoria il sovraccarico di struct
è di 160 byte.
Il costo pratico può comportare molti più errori di cache obbligatori e non obbligatori con la tabella dei puntatori di funzioni rispetto alla classe che utilizza funzioni virtuali (e possibilmente errori di pagina su una scala di input sufficientemente ampia). Tale costo tende a sminuire il lavoro leggermente aggiuntivo dell'indicizzazione di una tabella virtuale.
Ho anche avuto a che fare con basi di codice C legacy (più vecchie di me) in cui trasformare tali structs
pieni di puntatori di funzioni e istanziato numerose volte, in realtà ha dato significativi miglioramenti delle prestazioni (miglioramenti di oltre il 100%) trasformandoli in classi con funzioni virtuali e semplicemente a causa della massiccia riduzione dell'uso della memoria, della maggiore compatibilità con la cache, ecc.
Il rovescio della medaglia, quando i confronti diventano di più sulle mele alle mele, ho anche trovato la mentalità opposta di tradurre da una mentalità di funzione virtuale C ++ a una mentalità di puntatore di funzione in stile C per essere utile in questi tipi di scenari:
class Functionoid
{
public:
virtual ~Functionoid() {}
virtual void operator()() = 0;
};
... in cui la classe memorizzava una singola funzione misurabile scavalcabile (o due se contiamo il distruttore virtuale). In questi casi, può sicuramente aiutare in percorsi critici a trasformarlo in questo:
void (*func_ptr)(void* instance_data);
... idealmente dietro un'interfaccia di tipo sicuro per nascondere i lanci pericolosi da / verso void*
.
In quei casi in cui siamo tentati di usare una classe con una singola funzione virtuale, può invece aiutare rapidamente a utilizzare i puntatori a funzione. Un grande motivo non è nemmeno necessariamente il costo ridotto nel chiamare un puntatore a funzione. È perché non affrontiamo più la tentazione di allocare ciascun funzionaleide separato sulle regioni sparse dell'heap se le stiamo aggregando in una struttura persistente. Questo tipo di approccio può semplificare l'eventuale sovraccarico associato all'heap e alla frammentazione della memoria se i dati dell'istanza sono omogenei, ad esempio, e solo il comportamento varia.
Quindi ci sono sicuramente alcuni casi in cui l'uso dei puntatori a funzione può aiutare, ma spesso l'ho trovato al contrario se stiamo confrontando un mucchio di tabelle di puntatori a funzione con una singola vtable che richiede solo un puntatore per essere archiviato per istanza di classe . Quella vtable si troverà spesso in una o più linee di cache L1 e in loop stretti.
Conclusione
Quindi comunque, questo è il mio piccolo giro su questo argomento. Consiglio di avventurarsi in queste aree con cautela. Fidarsi delle misurazioni, non dell'istinto, e dato il modo in cui queste ottimizzazioni spesso degradano la manutenibilità, vanno solo per quanto è possibile permettersi (e una strada saggia sarebbe quella di sbagliare dal lato della manutenibilità).