In generale, vale la pena usare le funzioni virtuali per evitare la ramificazione?


21

Sembra che ci siano equivalenti approssimativi di istruzioni per equiparare al costo di una filiale che le funzioni virtuali hanno un compromesso simile:

  • istruzione vs. cache dati mancata
  • barriera di ottimizzazione

Se guardi qualcosa come:

if (x==1) {
   p->do1();
}
else if (x==2) {
   p->do2();
}
else if (x==3) {
   p->do3();
}
...

È possibile disporre di un array di funzioni membro o se molte funzioni dipendono dalla stessa categorizzazione o esiste una categorizzazione più complessa, utilizzare le funzioni virtuali:

p->do()

Ma, in generale, quanto sono costose le funzioni virtuali rispetto alla ramificazione È difficile testare su piattaforme sufficienti per generalizzare, quindi mi chiedevo se qualcuno avesse una regola empirica approssimativa (adorabile se fosse semplice quanto 4 ifs è il punto di interruzione)

In generale le funzioni virtuali sono più chiare e mi spingerei verso di loro. Ma ho diverse sezioni altamente critiche in cui posso cambiare il codice da funzioni virtuali a rami. Preferirei avere pensieri su questo prima di intraprendere questo. (non è un cambiamento banale o facile da testare su più piattaforme)


12
Bene, quali sono i tuoi requisiti di prestazione? Hai numeri concreti che devi colpire o ti impegni nell'ottimizzazione prematura? Sia il branching che i metodi virtuali sono estremamente economici nel grande schema delle cose (ad esempio rispetto ad algoritmi errati, I / O o allocazione di heap).
amon,

4
Fate quello che è più leggibile / flessibili / improbabile ottenere nel modo di cambiamenti futuri, e una volta che lo avete a lavorare poi non profilatura e vedere se questo conta davvero. Di solito no.
Ixrec,

1
Domanda: "Ma, in generale, quanto costano le funzioni virtuali ..." Risposta: Branch indiretto (wikipedia)
rwong

1
Ricorda che la maggior parte delle risposte si basa sul conteggio del numero di istruzioni. Come ottimizzatore di codice di basso livello, non mi fido del numero di istruzioni; è necessario provarli su una particolare architettura della CPU - fisicamente - in condizioni sperimentali. Le risposte valide per questa domanda devono essere empiriche e sperimentali, non teoriche.
dal

3
Il problema con questa domanda è che presuppone che questo sia abbastanza grande da preoccuparsi. Nel software reale, i problemi di prestazioni si presentano in grossi pezzi, come fette di pizza di più dimensioni. Ad esempio guarda qui . Non dare per scontato di sapere qual è il problema più grande: lascia che il programma ti dica. Risolvilo e poi lascia che ti dica qual è il prossimo. Fallo una mezza dozzina di volte e potresti essere preoccupato per le chiamate alle funzioni virtuali. Non hanno mai avuto, nella mia esperienza.
Mike Dunlavey,

Risposte:


21

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 switcheso if/elserami 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 ( switcho un mucchio di if/elseaffermazioni). 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 switchdichiarazione 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_ptrqui.

... dov'è Creatureun 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 newper 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 creaturescontenitore 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 humansin un loop e other_creaturesin 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_somethingstato 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 switchun'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 classcon 20 funzioni virtuali con a structche memorizza 20 puntatori a funzione ed entrambi sono istanziati più volte, l'overhead di memoria di ciascuna classistanza 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 structspieni 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à).


Le funzioni virtuali sono puntatori a funzioni, implementate nel modo fattibile di quella classe. Quando viene chiamata una funzione virtuale, viene prima cercata nel figlio e nella catena ereditaria. Questo è il motivo per cui l'eredità profonda è molto costosa ed è generalmente evitata in c ++.
Robert Baron,

@RobertBaron: non ho mai visto implementare funzioni virtuali come hai detto (= con una ricerca a catena attraverso la gerarchia di classi). Generalmente i compilatori generano semplicemente una tabella "appiattita" per ogni tipo di calcestruzzo con tutti i puntatori di funzione corretti, e in fase di esecuzione la chiamata viene risolta con una singola ricerca di una tabella diretta; nessuna penalità viene pagata per le gerarchie di eredità profonde.
Matteo Italia,

Matteo, questa è stata la spiegazione che una guida tecnica mi ha dato molti anni fa. Certo, era per c ++, quindi potrebbe aver preso in considerazione le implicazioni dell'ereditarietà multipla. Grazie per aver chiarito la mia comprensione di come i vtables sono ottimizzati.
Robert Baron,

Grazie per la buona risposta (+1). Mi chiedo quanto di tutto ciò valga in modo identico per std :: visit invece che per le funzioni virtuali.
DaveFar il

13

osservazioni:

  • In molti casi, le funzioni virtuali sono più veloci perché la ricerca vtable è O(1)un'operazione mentre la else if()scala è O(n)un'operazione. Tuttavia, questo è vero solo se la distribuzione dei casi è piatta.

  • Per un singolo if() ... else, il condizionale è più veloce perché si salva l'overhead della chiamata di funzione.

  • Pertanto, quando si dispone di una distribuzione piatta dei casi, deve esistere un punto di pareggio. L'unica domanda è dove si trova.

  • Se si utilizza un switch()posto di else if()scala o di funzione virtuale chiamate, il compilatore può produrre ancora meglio di codice: si può fare un ramo in una posizione che è lo sguardo da una tabella, ma che non è una chiamata di funzione. Cioè, hai tutte le proprietà della chiamata di funzione virtuale senza tutte le spese generali della chiamata di funzione.

  • Se uno è molto più frequente del resto, iniziare if() ... elsecon quel caso ti darà le migliori prestazioni: Eseguirai un singolo ramo condizionale che è correttamente previsto nella maggior parte dei casi.

  • Il compilatore non è a conoscenza della distribuzione prevista dei casi e assumerà una distribuzione piatta.

Dal momento che il tuo compilatore probabilmente ha una buona euristica in atto su quando codificare un switch()come else if()scala o come tabella di ricerca. Tenderei ad avere fiducia nel suo giudizio a meno che tu non sappia che la distribuzione dei casi è parziale.

Quindi, il mio consiglio è questo:

  • Se uno dei casi riduce il resto in termini di frequenza, utilizzare una else if()scala ordinata .

  • Altrimenti usa switch()un'istruzione, a meno che uno degli altri metodi renda il tuo codice molto più leggibile. Assicurati di non acquistare un guadagno in termini di prestazioni trascurabile con una leggibilità significativamente ridotta.

  • Se hai utilizzato a switch()e non sei ancora soddisfatto delle prestazioni, fai il confronto, ma preparati a scoprire che switch()era già la possibilità più veloce.


2
Alcuni compilatori consentono alle annotazioni di dire al compilatore quale caso è più probabile che sia vero e quei compilatori possono produrre codice più veloce purché l'annotazione sia corretta.
gnasher729,

5
un'operazione O (1) non è necessariamente più veloce nel tempo di esecuzione nel mondo reale di una O (n) o addirittura O (n ^ 20).
whatsisname

2
@whatsisname Ecco perché ho detto "per molti casi". Dalla definizione di O(1)e O(n)esiste un a in kmodo che la O(n)funzione sia maggiore della O(1)funzione per tutti n >= k. L'unica domanda è se è probabile che tu abbia così tanti casi. E, sì, ho visto switch()dichiarazioni con così tanti casi che una else if()ladder è decisamente più lenta di una chiamata di funzione virtuale o di una spedizione caricata.
cmaster - ripristina monica il

Il problema che ho con questa risposta è l'unico avvertimento contro il prendere una decisione basata su un guadagno di prestazione completamente irrilevante è nascosto da qualche parte nel prossimo penultimo paragrafo. Tutto il resto qui finge che possa essere una buona idea prendere una decisione sulle funzioni ifvs switchvs. virtuali basate sulla perfomance. In casi estremamente rari può essere, ma nella maggior parte dei casi non lo è.
Doc Brown

7

In generale, vale la pena usare le funzioni virtuali per evitare la ramificazione?

In generale si. I vantaggi per la manutenzione sono significativi (test di separazione, separazione dei problemi, migliore modularità ed estensibilità).

Ma, in generale, quanto sono costose le funzioni virtuali rispetto alla ramificazione È difficile testare su piattaforme sufficienti per generalizzare, quindi mi chiedevo se qualcuno avesse una regola empirica approssimativa (adorabile se fosse semplice come se il punto di interruzione fosse 4)

A meno che tu non abbia profilato il tuo codice e sappia che l'invio tra filiali ( la valutazione delle condizioni ) richiede più tempo rispetto ai calcoli eseguiti ( il codice nei rami ), ottimizza i calcoli eseguiti.

Cioè, la risposta corretta a "quanto sono costose le funzioni virtuali rispetto alle ramificazioni" è misurare e scoprire.

Regola empirica : a meno che non si verifichi la situazione precedente (discriminazione delle filiali più costosa rispetto ai calcoli delle filiali), ottimizzare questa parte del codice per gli sforzi di manutenzione (utilizzare le funzioni virtuali).

Dici di voler eseguire questa sezione il più velocemente possibile; Quanto è veloce? Qual è il tuo requisito concreto?

In generale le funzioni virtuali sono più chiare e mi spingerei verso di loro. Ma ho diverse sezioni altamente critiche in cui posso cambiare il codice da funzioni virtuali a rami. Preferirei avere pensieri su questo prima di intraprendere questo. (non è un cambiamento banale o facile da testare su più piattaforme)

Usa quindi le funzioni virtuali. Ciò consentirà persino di ottimizzare per piattaforma, se necessario, e mantenere comunque pulito il codice client.


Dopo aver programmato molto la manutenzione, vado avanti con un po 'di cautela: le funzioni virtuali sono IMNSHO piuttosto dannose per la manutenzione, proprio per i vantaggi che elenchi. Il problema principale è la loro flessibilità; potresti attaccare praticamente qualsiasi cosa lì dentro ... e la gente lo fa. È molto difficile ragionare staticamente sulla spedizione dinamica. Tuttavia, nella maggior parte dei casi specifici, il codice non ha bisogno di tutta quella flessibilità e la rimozione della flessibilità di runtime può rendere più semplice ragionare sul codice. Eppure non voglio spingermi fino al punto di dire che non dovresti mai usare il dispacciamento dinamico; è assurdo.
Eamon Nerbonne,

Le astrazioni più belle con cui lavorare sono quelle rare (ovvero una base di codice ha solo alcune astrazioni opache), ma robusta da super-duper. Fondamentalmente: non attaccare qualcosa dietro un'astrazione di spedizione dinamica solo perché sembra avere una forma simile per un caso particolare; fallo solo se non puoi ragionevolmente concepire alcun motivo per preoccuparti di una distinzione tra gli oggetti che condividono quell'interfaccia. Se non puoi: meglio avere un aiutante non incapsulante che un'astrazione che perde. E anche allora; c'è un compromesso tra flessibilità di runtime e flessibilità di codebase.
Eamon Nerbonne,

5

Le altre risposte forniscono già buoni argomenti teorici. Vorrei aggiungere i risultati di un esperimento che ho eseguito di recente per stimare se sarebbe una buona idea implementare una macchina virtuale (VM) utilizzando un grande switchcodice operativo o piuttosto interpretare il codice operativo come un indice in una matrice di puntatori a funzione. Sebbene questa non sia esattamente la stessa di una virtualchiamata di funzione, penso che sia ragionevolmente vicina.

Ho scritto uno script Python per generare casualmente il codice C ++ 14 per una VM con una dimensione del set di istruzioni scelta in modo casuale (anche se non uniformemente, campionando la gamma bassa in modo più denso) tra 1 e 10000. La VM generata ha sempre avuto 128 registri e no RAM. Le istruzioni non sono significative e hanno tutte il seguente modulo.

inline void
op0004(machine_state& state) noexcept
{
  const auto c = word_t {0xcf2802e8d0baca1dUL};
  const auto r1 = state.registers[58];
  const auto r2 = state.registers[69];
  const auto r3 = ((r1 + c) | r2);
  state.registers[6] = r3;
}

Lo script genera anche routine di invio utilizzando switchun'istruzione ...

inline int
dispatch(machine_state& state, const opcode_t opcode) noexcept
{
  switch (opcode)
  {
  case 0x0000: op0000(state); return 0;
  case 0x0001: op0001(state); return 0;
  // ...
  case 0x247a: op247a(state); return 0;
  case 0x247b: op247b(state); return 0;
  default:
    return -1;  // invalid opcode
  }
}

... e una serie di puntatori a funzioni.

inline int
dispatch(machine_state& state, const opcode_t opcode) noexcept
{
  typedef void (* func_type)(machine_state&);
  static const func_type table[VM_NUM_INSTRUCTIONS] = {
    op0000,
    op0001,
    // ...
    op247a,
    op247b,
  };
  if (opcode >= VM_NUM_INSTRUCTIONS)
    return -1;  // invalid opcode
  table[opcode](state);
  return 0;
}

La routine di spedizione generata è stata scelta casualmente per ogni VM generata.

Per l'analisi comparativa, il flusso di codici operativi è stato generato da un std::random_devicemotore casuale twister ( ) di Mersenne con seeding casuale ( std::mt19937_64).

Il codice per ogni VM è stato compilato con GCC 5.2.0 utilizzando -DNDEBUG, -O3e -std=c++14interruttori. Innanzitutto, è stato compilato utilizzando l' -fprofile-generateopzione e i dati del profilo raccolti per simulare 1000 istruzioni casuali. Il codice è stato quindi ricompilato con l' -fprofile-useopzione che consente le ottimizzazioni basate sui dati del profilo raccolti.

La VM è stata quindi esercitata (nello stesso processo) quattro volte per 50.000.000 di cicli e misurato il tempo per ciascuna corsa. La prima esecuzione è stata scartata per eliminare gli effetti della cache fredda. Il PRNG non è stato riprogrammato tra le serie in modo che non eseguissero la stessa sequenza di istruzioni.

Usando questa configurazione, sono stati raccolti 1000 punti dati per ciascuna routine di dispacciamento. I dati sono stati raccolti su un'APU AMD A8-6600K quad core con 2048 KiB cache che esegue GNU / Linux a 64 bit senza un desktop grafico o altri programmi in esecuzione. Di seguito è mostrato un grafico del tempo medio della CPU (con deviazione standard) per istruzione per ogni VM.

inserisci qui la descrizione dell'immagine

Da questi dati, potrei guadagnare la certezza che l'uso di una tabella di funzioni è una buona idea, tranne forse per un numero molto piccolo di codici operativi. Non ho una spiegazione per i valori anomali della switchversione tra 500 e 1000 istruzioni.

Tutto il codice sorgente per il benchmark, nonché i dati sperimentali completi e un grafico ad alta risoluzione sono disponibili sul mio sito Web .


3

Oltre alla buona risposta di cmaster, che ho votato a favore, tieni presente che i puntatori a funzioni sono generalmente più veloci delle funzioni virtuali. L'invio di funzioni virtuali generalmente implica prima di seguire un puntatore dall'oggetto alla vtable, indicizzandolo in modo appropriato e quindi dereferenziando un puntatore a funzione. Quindi il passaggio finale è lo stesso, ma inizialmente ci sono passaggi aggiuntivi. Inoltre, le funzioni virtuali prendono sempre "this" come argomento, i puntatori a funzioni sono più flessibili.

Un'altra cosa da tenere a mente: se il percorso critico prevede un ciclo, può essere utile ordinare il ciclo in base alla destinazione di spedizione. Ovviamente questo è nlogn, mentre attraversare il ciclo è solo n, ma se hai intenzione di attraversare molte volte questo può valerne la pena. Ordinando per destinazione di spedizione, si assicura che lo stesso codice venga eseguito ripetutamente, mantenendolo caldo in icache, riducendo al minimo i mancati cache.

Una terza strategia da tenere a mente: se decidi di allontanarti da funzioni virtuali / puntatori di funzioni verso strategie if / switch, potresti anche essere ben servito passando da oggetti polimorfici a qualcosa come boost :: variant (che fornisce anche lo switch caso sotto forma di astrazione del visitatore). Gli oggetti polimorfici devono essere archiviati dal puntatore di base, quindi i tuoi dati si trovano ovunque nella cache. Questo potrebbe facilmente influenzare maggiormente il tuo percorso critico rispetto al costo della ricerca virtuale. Considerando che la variante è archiviata in linea come unione discriminata; ha dimensioni pari al tipo di dati più grande (più una piccola costante). Se i tuoi oggetti non differiscono troppo nelle dimensioni, questo è un ottimo modo per gestirli.

In realtà, non sarei sorpreso se il miglioramento della coerenza della cache dei tuoi dati avrebbe un impatto maggiore rispetto alla tua domanda originale, quindi esaminerei sicuramente di più.


Non so però che una funzione virtuale implichi "passaggi extra". Dato che il layout della classe è noto al momento della compilazione, è essenzialmente lo stesso di un accesso alla matrice. Vale a dire che c'è un puntatore all'inizio della classe e l'offset della funzione è noto, quindi basta aggiungerlo, leggere il risultato e questo è l'indirizzo. Non molto sovraccarico.

1
Implica passaggi aggiuntivi. La stessa vtable contiene i puntatori a funzione, quindi quando si passa alla vtable, si è raggiunto lo stesso stato in cui si era avviato con un puntatore a funzione. Tutto prima di arrivare alla vtable è un lavoro extra. Le classi non contengono le loro vtabili, contengono i puntatori alle vtabili e seguire quel puntatore è una dereferenza aggiuntiva. In effetti, a volte c'è una terza dereferenza poiché le classi polimorfiche sono generalmente trattenute dal puntatore della classe di base, quindi devi dereferenziare un puntatore per ottenere l'indirizzo vtable (per dereferenziarlo ;-)).
Nir Friedman,

D'altro canto, il fatto che la vtable sia memorizzata all'esterno dell'istanza può effettivamente essere utile per la località temporale rispetto, per esempio, a una serie di strutture disparate di puntatori a funzione in cui ogni puntatore a funzione è memorizzato in un indirizzo di memoria diverso. In questi casi una singola vtable con un milione di vptr può battere facilmente un milione di tabelle di puntatori di funzioni (a partire solo dal consumo di memoria). Può essere un po 'complicato qui - non è così facile da abbattere. In generale, sono d'accordo sul fatto che il puntatore a funzione è spesso un po 'più economico, ma non è così facile metterne uno sopra l'altro.

Penso, per dirla in altro modo, in cui le funzioni virtuali iniziano a sovraperformare in modo rapido e grossolano i puntatori a funzione è quando si ha un carico di istanze di oggetti coinvolto (dove ogni oggetto dovrebbe archiviare puntatori a più funzioni o un singolo vptr). I puntatori a funzione tendono ad essere più economici se, ad esempio, si dispone di un solo puntatore a funzione memorizzato che verrà chiamato un carico di volte. In caso contrario, i puntatori a funzione possono iniziare a rallentare con la quantità di ridondanza dei dati e gli errori nella cache che risultano da molte memorie di ridondanza e puntano allo stesso indirizzo.

Ovviamente con i puntatori a funzione, è anche possibile memorizzarli in una posizione centrale anche se sono condivisi da un milione di oggetti separati per evitare di caricare la memoria e ottenere un carico di missioni di cache. Ma poi iniziano a diventare equivalenti ai vpointer, implicando l'accesso del puntatore a una posizione condivisa in memoria per raggiungere gli indirizzi di funzione che vogliamo chiamare. La domanda fondamentale qui è: memorizzi l'indirizzo della funzione più vicino ai dati a cui stai attualmente accedendo o in una posizione centrale? vtables consente solo quest'ultima. I puntatori a funzione consentono entrambi i modi.

2

Posso solo spiegare perché penso che questo sia un problema XY ? (Non sei il solo a chiedere loro.)

Presumo che il tuo vero obiettivo sia quello di risparmiare tempo in generale, non solo di capire un punto su mancati cache e funzioni virtuali.

Ecco un esempio di ottimizzazione delle prestazioni reali , nel software reale.

Nel software reale, le cose si ottengono, indipendentemente dall'esperienza del programmatore, si potrebbe fare di meglio. Non si sa cosa siano fino a quando il programma non viene scritto e non è possibile eseguire il tuning delle prestazioni. Ci sono quasi sempre più di un modo per accelerare il programma. Dopotutto, per dire che un programma è ottimale, stai dicendo che nel pantheon di possibili programmi per risolvere il tuo problema, nessuno di loro impiega meno tempo. Veramente?

Nell'esempio a cui mi sono collegato, originariamente sono stati necessari 2700 microsecondi per "lavoro". Una serie di sei problemi sono stati risolti, andando in senso antiorario attorno alla pizza. Il primo speedup ha rimosso il 33% delle volte. Il secondo ha rimosso l'11%. Ma nota, il secondo non era dell'11% al momento della sua scoperta, era del 16%, perché il primo problema era scomparso . Allo stesso modo, il terzo problema è stato ingrandito dal 7,4% al 13% (quasi il doppio) perché i primi due problemi erano spariti.

Alla fine, questo processo di ingrandimento ha permesso di eliminare tutti tranne 3,7 microsecondi. Questo è lo 0,14% del tempo originale o uno speedup di 730x.

inserisci qui la descrizione dell'immagine

La rimozione dei problemi inizialmente grandi comporta una moderata accelerazione, ma aprono la strada alla rimozione dei problemi successivi. Questi problemi successivi potrebbero inizialmente essere stati parti insignificanti del totale, ma dopo che i primi problemi sono stati rimossi, questi piccoli diventano grandi e possono produrre grandi accelerazioni. (È importante capire che, per ottenere questo risultato, nessuno può mancare, e questo post mostra quanto facilmente possano essere.)

inserisci qui la descrizione dell'immagine

Il programma finale è stato ottimale? Probabilmente no. Nessuno degli speedUp ha avuto a che fare con i mancati cache. I cache cache ora contano? Può essere.

EDIT: sto ricevendo voti negativi dalle persone che si avvicinano alle "sezioni altamente critiche" della domanda del PO. Non sai che qualcosa è "altamente critico" fino a quando non sai quale frazione di tempo rappresenta. Se il costo medio di questi metodi chiamati è di 10 cicli o più, nel tempo, il metodo di invio a questi probabilmente non è "critico", rispetto a quello che stanno effettivamente facendo. Lo vedo più e più volte, in cui le persone trattano il "bisogno di ogni nanosecondo" come una ragione per essere pazzesche e libbre.


ha già detto di avere diverse "sezioni altamente critiche" che richiedono ogni ultimo nanosecondo di prestazioni. Quindi questa non è una risposta alla domanda che ha posto (anche se sarebbe un'ottima risposta alla domanda di qualcun altro)
gbjbaanb

2
@gbjbaanb: se ogni ultimo nanosecondo conta, perché la domanda inizia con "in generale"? Questa è una sciocchezza. Quando i nanosecondi contano, non puoi cercare risposte generali, guardi cosa fa il compilatore, guardi cosa fa l'hardware, provi le variazioni e misuri ogni variazione.
gnasher729,

@ gnasher729 Non lo so, ma perché finisce con "sezioni altamente critiche"? Immagino, come slashdot, si dovrebbe sempre leggere il contenuto e non solo il titolo!
gbjbaanb,

2
@gbjbaanb: tutti dicono di avere "sezioni altamente critiche". Come fanno a saperlo? Non so che qualcosa sia critico fino a quando non prendo, diciamo, 10 campioni, e lo vedo su 2 o più di essi. In un caso come questo, se i metodi chiamati richiedono più di 10 istruzioni, l'overhead della funzione virtuale è probabilmente insignificante.
Mike Dunlavey,

@ gnasher729: Bene, la prima cosa che faccio è ottenere degli stack stack e, su ciascuno, esaminare cosa sta facendo il programma e perché. Quindi, se trascorre tutto il suo tempo nelle foglie dell'albero delle chiamate e tutte le chiamate sono davvero inevitabili , importa cosa fanno il compilatore e l'hardware. Sai che la spedizione del metodo conta solo se i campioni arrivano durante il processo di spedizione del metodo.
Mike Dunlavey,
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.