Suggerimenti per l'ottimizzazione di basso livello C ++ [chiuso]


79

Supponendo che tu abbia già l'algoritmo della scelta migliore, quali soluzioni di basso livello puoi offrire per spremere le ultime gocce di dolce frequenza dei frame dal codice C ++?

Inutile dire che questi suggerimenti si applicano solo alla sezione di codice critico che hai già evidenziato nel tuo profiler, ma dovrebbero essere miglioramenti non strutturali di basso livello. Ho fatto un esempio.


1
Ciò che rende questo una domanda di sviluppo del gioco e non una questione di programmazione generale, come questi: stackoverflow.com/search?q=c%2B%2B+optimization
Danny Varod

@Danny - Probabilmente questa potrebbe essere una domanda di programmazione generale. È anche sicuramente una domanda relativa alla programmazione di giochi. Penso che sia una domanda praticabile su entrambi i siti.
Smashery,

@Smashery L'unica differenza tra i due è che la programmazione del gioco può richiedere specifiche ottimizzazioni del livello del motore grafico o ottimizzazioni del codificatore di shader, la parte C ++ è la stessa.
Danny Varod,

@Danny - Vero, alcune domande saranno "più" rilevanti su un sito o sull'altro; ma non vorrei respingere le domande pertinenti solo perché potrebbero essere poste anche su un altro sito.
Smashery,

Risposte:


76

Ottimizza il layout dei tuoi dati! (Questo vale per più lingue oltre al C ++)

Puoi andare abbastanza in profondità rendendolo appositamente sintonizzato per i tuoi dati, il tuo processore, la gestione multi-core in modo piacevole, ecc. Ma il concetto di base è questo:

Quando si elaborano le cose in un ciclo stretto, si desidera rendere i dati per ciascuna iterazione il più piccoli possibile e il più vicini possibile nella memoria. Ciò significa che l'ideale è un array o un vettore di oggetti (non puntatori) che contengono solo i dati necessari per il calcolo.

In questo modo, quando la CPU recupera i dati per la prima iterazione del tuo ciclo, le successive diverse iterazioni di dati verranno caricate nella cache con esso.

Davvero la CPU è veloce e il compilatore è buono. Non c'è davvero molto che puoi fare usando meno istruzioni e più veloci. La coerenza della cache è dove si trova (questo è un articolo casuale che ho cercato su Google - contiene un buon esempio di come ottenere la coerenza della cache per un algoritmo che non scorre semplicemente attraverso i dati in modo lineare).


Vale la pena provare l'esempio C nella pagina di coerenza della cache collegata. Quando l'ho scoperto per la prima volta, sono rimasto scioccato da quanta differenza fa.
Neel,

9
Vedi anche le eccellenti insidie ​​della presentazione della programmazione orientata agli oggetti (Ricerca e Sviluppo Sony) ( research.scee.net/files/presentations/gcapaustralia09/… ) - e gli articoli irritabili ma affascinanti di CellPerformance di Mike Acton ( cellperformance.beyond3d.com/articles/ index.html ). Anche il blog Games from Within di Noel Llopis tocca frequentemente questo argomento ( gamesfromwithin.com ). Non posso raccomandare abbastanza le diapositive di insidie ​​...
magro

2
Avrei solo avvertito di "rendere i dati per ogni iterazione il più piccolo possibile e il più vicino possibile nella memoria" . L'accesso ai dati non allineati può rallentare le cose; nel qual caso l'imbottitura darà prestazioni migliori. Anche l' ordine dei dati è importante, poiché anche i dati ordinati possono portare a una riduzione del riempimento. Scott Mayers può spiegarlo meglio di quanto io possa pensare :)
Jonathan Connell

+1 alla presentazione Sony. L'ho già letto in precedenza e ha davvero senso su come ottimizzare i dati a livello di piattaforma, tenendo in considerazione la suddivisione dei dati in blocchi e l'allineamento corretto.
ChrisC,

84

Un suggerimento di livello molto, molto basso, ma che può tornare utile:

La maggior parte dei compilatori supporta una qualche forma di suggerimento condizionale esplicito. GCC ha una funzione chiamata __builtin_expect che ti consente di informare il compilatore quale sia probabilmente il valore di un risultato. GCC può utilizzare tali dati per ottimizzare i condizionali per eseguire il più rapidamente possibile nel caso previsto, con un'esecuzione leggermente più lenta nel caso imprevisto.

if(__builtin_expect(entity->extremely_unlikely_flag, 0)) {
  // code that is rarely run
}

Ho visto una velocità del 10-20% con un uso corretto di questo.


1
Vorrei votare due volte se potessi.
tenpn,

10
+1, il kernel Linux lo utilizza ampiamente per le microottimizzazioni nel codice dello scheduler e fa una differenza significativa in determinati percorsi del codice.
Greyfade,

2
Sfortunatamente, non sembra esserci un buon equivalente in Visual Studio. stackoverflow.com/questions/1440570/…
mmyers

1
Quindi a quale frequenza il valore atteso di solito dovrebbe essere quello corretto per ottenere prestazioni? 49/50 volte? O 999999/1000000 volte?
Douglas,

36

La prima cosa che devi capire è l'hardware su cui stai eseguendo. Come gestisce le ramificazioni? Che dire della memorizzazione nella cache? Ha un set di istruzioni SIMD? Quanti processori può usare? Deve condividere il tempo del processore con qualcos'altro?

È possibile risolvere lo stesso problema in modi molto diversi: anche la scelta dell'algoritmo dovrebbe dipendere dall'hardware. In alcuni casi O (N) può funzionare più lentamente di O (NlogN) (a seconda dell'implementazione).

Come grossolana panoramica sull'ottimizzazione, la prima cosa che vorrei fare è guardare esattamente quali problemi e quali dati stai cercando di risolvere. Quindi ottimizzare per quello. Se desideri prestazioni estreme, dimentica le soluzioni generiche: puoi applicare un caso speciale a tutto ciò che non corrisponde al caso più utilizzato.

Quindi profilo. Profilo, profilo, profilo. Osserva l'utilizzo della memoria, osserva le penalità di ramificazione, Guarda l'overhead della chiamata di funzione, osserva l'utilizzo della pipeline. Scopri cosa sta rallentando il tuo codice. Probabilmente è l'accesso ai dati (ho scritto un articolo chiamato "L'elefante della latenza" sul sovraccarico dell'accesso ai dati - google esso. Non posso pubblicare 2 link qui perché non ho abbastanza "reputazione"), quindi esaminalo attentamente e quindi ottimizza il layout dei tuoi dati ( le matrici omogenee grandi e piatte sono fantastiche ) e l'accesso ai dati (prefetch ove possibile).

Una volta minimizzato il sovraccarico del sottosistema di memoria, prova a determinare se le istruzioni sono ora il collo di bottiglia (si spera che lo siano), quindi guarda le implementazioni SIMD del tuo algoritmo - Le implementazioni di Structure-of-Arrays (SoA) possono essere molto dati e cache delle istruzioni efficiente. Se SIMD non è adatto per il tuo problema, potrebbe essere necessaria la codifica a livello di intrinseco e assemblatore.

Se hai ancora bisogno di più velocità, vai in parallelo. Se hai il vantaggio di correre su una PS3, le SPU sono i tuoi amici. Usali, amali. Se hai già scritto una soluzione SIMD, otterrai un enorme vantaggio passando a SPU.

E poi, profila ancora un po '. Prova negli scenari di gioco - questo codice è ancora il collo di bottiglia? Puoi cambiare il modo in cui questo codice viene utilizzato a un livello superiore per minimizzarne l'utilizzo (in realtà, questo dovrebbe essere il tuo primo passo)? Puoi rinviare i calcoli su più frame?

Qualunque sia la piattaforma su cui ti trovi, impara il più possibile sull'hardware e sui profili disponibili. Non dare per scontato di sapere qual è il collo di bottiglia: trovalo con il tuo profiler. E assicurati di avere un'euristica per determinare se hai effettivamente reso il tuo gioco più veloce.

E quindi profilalo di nuovo.


31

Primo passo: pensa attentamente ai tuoi dati in relazione ai tuoi algoritmi. O (log n) non è sempre più veloce di O (n). Esempio semplice: una tabella hash con solo poche chiavi viene spesso sostituita con una ricerca lineare.

Secondo passaggio: guarda l'assembly generato. Il C ++ porta molta generazione di codice implicito nella tabella. A volte, ti intrufola a tua insaputa.

Ma supponendo che sia davvero tempo di pedalare sul metallo: il profilo. Sul serio. L'applicazione casuale di "trucchi prestazionali" ha la stessa probabilità di ferire quanto di aiutare.

Quindi, tutto dipende da quali sono i tuoi colli di bottiglia.

data cache misses => ottimizza il layout dei dati. Ecco un buon punto di partenza: http://gamesfromwithin.com/data-oriented-design

code cache misses => Guarda le chiamate di funzione virtuale, l'eccessiva profondità del callstack, ecc. Una causa comune di cattive prestazioni è l'errata convinzione che le classi di base debbano essere virtuali.

Altri dissipatori di prestazioni C ++ comuni:

  • Assegnazione / deallocazione eccessive. Se è fondamentale per le prestazioni, non chiamare in runtime. Mai.
  • Copia la costruzione. Evita dove puoi. Se può essere un riferimento const, rendilo uno.

Tutto quanto sopra è immediatamente evidente quando guardi l'assemblaggio, quindi vedi sopra;)


19

Rimuovere i rami non necessari

Su alcune piattaforme e con alcuni compilatori, i rami possono buttare via tutta la pipeline, quindi anche se () i blocchi possono essere costosi.

L'architettura PowerPC (PS3 / x360) offre select istruzioni in virgola mobile, fsel. Questo può essere usato al posto di un ramo se i blocchi sono semplici assegnazioni:

float result = 0;
if (foo > bar) { result = 2.0f; }
else { result = 1.0f; }

diventa:

float result = fsel(foo-bar, 2.0f, 1.0f);

Quando il primo parametro è maggiore o uguale a 0, viene restituito il secondo parametro, altrimenti il ​​terzo.

Il prezzo della perdita della diramazione è che verranno eseguiti sia il blocco if {} che il blocco else {}, quindi se si tratta di un'operazione costosa o si verifica un puntatore NULL, questa ottimizzazione non è adatta.

A volte il compilatore ha già fatto questo lavoro, quindi prima controlla l'assemblaggio.

Ecco ulteriori informazioni su branching e fsel:

http://assemblyrequired.crashworks.org/tag/intrinsics/


float result = (foo> bar)? 2.f: 1.f
knight666,

3
@ knight666: Ciò produrrà comunque un ramo ovunque che un "se" longhand avrebbe fatto. Lo dico così perché su ARM, almeno, piccole sequenze del genere possono essere implementate con istruzioni condizionali che non richiedono ramificazioni.
chrisbtoo,

1
@ knight666 se sei fortunato il compilatore può trasformarlo in un fsel, ma non è certo. FWIW, normalmente scrivo quel frammento con un operatore terziario, e successivamente ottimizzerò per fsel se il profiler fosse d'accordo.
tenpn

Su IA32 invece hai CMOVcc.
Skizz,

Vedi anche blueraja.com/blog/285/… (nota che in questo caso, se il compilatore è buono, dovrebbe essere in grado di ottimizzarlo da solo, quindi non è qualcosa di cui devi preoccuparti)
BlueRaja - Danny Pflughoeft

16

Evita gli accessi alla memoria e soprattutto quelli casuali a tutti i costi.

Questa è la cosa più importante da ottimizzare su CPU moderne. Puoi fare un carico di aritmetica e persino molti rami previsti sbagliati nel tempo in cui aspetti i dati dalla RAM.

Puoi anche leggere questa regola al contrario: fai quanti più calcoli possibile tra gli accessi alla memoria.



11

Rimuovere le chiamate di funzione virtuale non necessarie

L'invio di una funzione virtuale può essere molto lento. Questo articolo fornisce una buona spiegazione del perché. Se possibile, per le funzioni chiamate molte molte volte per frame, evitarle.

Puoi farlo in un paio di modi. A volte puoi semplicemente riscrivere le classi per non aver bisogno dell'ereditarietà - forse si scopre che MachineGun è l'unica sottoclasse di Arma e puoi combinarle.

È possibile utilizzare i modelli per sostituire il polimorfismo di runtime con il polimorfismo in fase di compilazione. Funziona solo se conosci il sottotipo dei tuoi oggetti in fase di esecuzione e può essere una riscrittura importante.


9

Il mio principio di base è: non fare nulla che non sia necessario .

Se hai scoperto che una particolare funzione è un collo di bottiglia, potresti ottimizzare la funzione o potresti provare a evitare che venga chiamata in primo luogo.

Questo non significa necessariamente che stai utilizzando un algoritmo non valido. Potrebbe significare che stai eseguendo calcoli su ogni frame che potrebbe essere memorizzato nella cache per un breve periodo (o interamente precalcolato), ad esempio.

Cerco sempre questo approccio prima di qualsiasi tentativo di ottimizzazione di basso livello.


2
Questa domanda presuppone che tu abbia già fatto tutto il materiale strutturale che puoi.
tenpn,

2
Lo fa. Ma spesso presumi di avere, e non l'hai fatto. Quindi, davvero, ogni volta che una funzione costosa deve essere ottimizzata, chiediti se è necessario chiamare quella funzione.
Rachel Blum,

2
... ma a volte può effettivamente essere più veloce fare il calcolo anche se in seguito butterai via il risultato, piuttosto che il ramo.
dieci


6

Ridurre al minimo le catene di dipendenze per sfruttare meglio la pipeline della CPU.

In casi semplici il compilatore può farlo per te se abiliti lo svolgersi del loop. Tuttavia, spesso non lo farà, specialmente quando sono coinvolti float poiché il riordino delle espressioni cambia il risultato.

Esempio:

float *data = ...;
int length = ...;

// Slow version
float total = 0.0f;
int i;
for (i=0; i < length; i++)
{
  total += data[i]
}

// Fast version
float total1, total2, total3, total4;
for (i=0; i < length-3; i += 4)
{
  total1 += data[i];
  total2 += data[i+1];
  total3 += data[i+2];
  total4 += data[i+3];
}
for (; i < length; i++)
{
  total += data[i]
}
total += (total1 + total2) + (total3 + total4);

4

Non trascurare il tuo compilatore: se stai usando gcc su Intel, potresti facilmente ottenere un miglioramento delle prestazioni passando al compilatore Intel C / C ++, ad esempio. Se stai prendendo di mira una piattaforma ARM, controlla il compilatore commerciale ARM. Se utilizzi l'iPhone, Apple ha appena consentito l'utilizzo di Clang a partire dall'SDK di iOS 4.0.

Un problema che probabilmente ti verrà in mente con l'ottimizzazione, specialmente su x86, è che molte cose intuitive finiscono per lavorare contro di te sulle moderne implementazioni della CPU. Sfortunatamente per la maggior parte di noi, la capacità di ottimizzare il compilatore è ormai lontana. Il compilatore può pianificare le istruzioni nello stream in base alla propria conoscenza interna della CPU. Inoltre, la CPU può anche riprogrammare le istruzioni in base alle proprie esigenze. Anche se pensi a un modo ottimale per organizzare un metodo, è probabile che il compilatore o la CPU abbiano già escogitato questo da solo e abbiano già eseguito tale ottimizzazione.

Il mio miglior consiglio sarebbe di ignorare le ottimizzazioni di basso livello e concentrarmi su quelle di livello superiore. Il compilatore e la CPU non possono cambiare l'algoritmo da un algoritmo O (n ^ 2) a O (1), non importa quanto riescano a ottenere. Ciò richiederà che tu guardi esattamente cosa stai cercando di fare e trovi un modo migliore per farlo. Lascia che il compilatore e la CPU si preoccupino del livello basso e ti concentri sui livelli medio-alti.


Capisco quello che stai dicendo, ma arriva un punto in cui hai raggiunto O (logN) e non otterrai più alcun cambiamento strutturale, in cui le ottimizzazioni di basso livello possono entrare in gioco e farti guadagnare quel mezzo millesimo di secondo in più.
tenpn,

1
Vedi la mia risposta su: O (log n). Inoltre, se cerchi mezzo millisecondo, potresti dover guardare al livello più alto. Questo è il 3% del tempo della trama!
Rachel Blum,

4

Il limitare parola chiave è potenzialmente utile, soprattutto nei casi in cui è necessario manipolare gli oggetti con i puntatori. Consente al compilatore di assumere che l'oggetto puntato non verrà modificato in alcun altro modo, il che a sua volta gli consente di eseguire un'ottimizzazione più aggressiva, come mantenere parti dell'oggetto nei registri o riordinare le letture e le scritture in modo più efficace.

Un aspetto positivo della parola chiave è che è un suggerimento che puoi applicare una volta e vedere i vantaggi senza riorganizzare l'algoritmo. Il lato negativo è che se lo usi in un posto sbagliato, potresti vedere la corruzione dei dati. Ma di solito è abbastanza facile individuare dove è legittimo usarlo - è uno dei pochi esempi in cui ci si può ragionevolmente aspettare che il programmatore sappia più di quanto il compilatore possa tranquillamente presumere, motivo per cui la parola chiave è stata introdotta.

Tecnicamente la "limitazione" non esiste nel C ++ standard, ma per la maggior parte dei compilatori C ++ sono disponibili equivalenti specifici della piattaforma, quindi vale la pena considerare.

Vedi anche: http://cellperformance.beyond3d.com/articles/2006/05/demystifying-the-restrict-keyword.html


2

Costante tutto!

Più informazioni dai al compilatore sui dati, migliori sono le ottimizzazioni (almeno nella mia esperienza).

void foo(Bar * x) {...;}

diventa;

void foo(const Bar * const x) {...;}

Il compilatore ora sa che il puntatore x non cambierà e che i dati a cui punta non cambieranno.

L'altro vantaggio aggiunto è che puoi ridurre il numero di bug accidentali, fermando te stesso (o altri) modificando cose che non dovrebbero.


E il tuo compagno di codice ti adorerà!
tenpn

4
constnon migliora le ottimizzazioni del compilatore. È vero che il compilatore può generare un codice migliore se sa che una variabile non cambierà, ma constnon fornisce una garanzia abbastanza forte.
deft_code

3
No. 'restringere' è molto più utile di 'const'. Vedi gamedev.stackexchange.com/questions/853/…
Justicle

+1 persona che dice che l'aiuto non può essere sbagliato ... infoq.com/presentations/kixeye-scalability
NoSenseEtAl

2

Molto spesso, il modo migliore per ottenere prestazioni è cambiare l'algoritmo. Meno generale è l'implementazione, più si avvicina il metallo.

Supponendo che sia stato fatto ....

Se è davvero un codice critico, cerca di evitare letture di memoria, cerca di evitare il calcolo di elementi che possono essere precalcolati (anche se nessuna tabella di ricerca in quanto violano la regola numero 1). Scopri cosa fa il tuo algoritmo e scrivilo in modo che anche il compilatore lo sappia. Controllare il gruppo per assicurarsi che lo faccia.

Evita mancate cache. Elaborazione in batch il più possibile. Evita le funzioni virtuali e altre indicazioni indirette.

Alla fine, misura tutto. Le regole cambiano continuamente. Ciò che era solito accelerare il codice 3 anni fa ora lo rallenta. Un bell'esempio è "usa le doppie funzioni matematiche invece delle versioni float". Non me ne sarei reso conto se non l'avessi letto.

Ho dimenticato - non avere costruttori predefiniti che inizializzano le tue variabili, o se insisti, almeno crea anche costruttori che non lo fanno. Fai attenzione alle cose che non compaiono nei profili. Quando perdi un ciclo non necessario per riga di codice, nel tuo profiler non verrà visualizzato nulla, ma nel complesso perderai molti cicli. Ancora una volta, sai cosa sta facendo il tuo codice. Rendi snella la tua funzione principale invece che infallibile. Le versioni a prova di errore possono essere chiamate se necessario, ma non sono sempre necessarie. La versatilità ha un prezzo: le prestazioni sono una cosa sola.

Modificato per spiegare perché nessuna inizializzazione predefinita: un sacco di codice dice: Vector3 bla; bla = DoSomething ();

L'intializzazione nel costruttore è tempo perso. Inoltre, in questo caso il tempo perso è piccolo (probabilmente azzerando il vettore), tuttavia se i vostri programmatori lo fanno abitualmente si sommano. Inoltre, molte funzioni creano un temporaneo (pensa agli operatori sovraccarichi), che viene inizializzato a zero e assegnato subito dopo. Cicli persi nascosti che sono troppo piccoli per vedere un picco nel tuo profiler, ma cicli di bleed in tutta la tua base di codice. Inoltre, alcune persone fanno molto di più nei costruttori (che è ovviamente un no-no). Ho visto guadagni di svariati millisecondi da una variabile inutilizzata in cui il costruttore era un po 'pesante. Non appena il costruttore provoca effetti collaterali, il compilatore non sarà in grado di disattivarlo, quindi a meno che tu non usi mai il codice sopra, preferisco un costruttore non inizializzante o, come ho detto,

Vector3 bla (noInit); bla = doSomething ();


/ Non / inizializzare i membri nei costruttori? In che modo aiuta?
tenpn

Vedi post modificato. Non rientrava nella casella dei commenti.
Kaj,

const Vector3 = doSomething()? Quindi l'ottimizzazione del valore di ritorno può dare il via e probabilmente dare origine a un compito o due.
tenpn

1

Ridurre la valutazione dell'espressione booleana

Questo è davvero disperato, in quanto è una modifica molto sottile ma pericolosa al tuo codice. Tuttavia, se si dispone di un condizionale che viene valutato un numero eccessivo di volte, è possibile ridurre il sovraccarico della valutazione booleana utilizzando invece operatori bit per bit. Così:

if ((foo && bar) || blah) { ... } 

diventa:

if ((foo & bar) | blah) { ... }

Utilizzando invece l'aritmetica dei numeri interi. Se i tuoi foo e bar sono costanti o valutati prima dell'if (), questo potrebbe essere più veloce della normale versione booleana.

Come bonus la versione aritmetica ha meno rami rispetto alla normale versione booleana. Qual è un altro modo per ottimizzare .

Il rovescio della medaglia è che si perde la valutazione pigra - l'intero blocco viene valutato, quindi non si può fare foo != NULL & foo->dereference(). Per questo motivo, è discutibile che sia difficile da mantenere, e quindi il compromesso potrebbe essere troppo grande.


1
Questo è un compromesso abbastanza egregio per motivi di prestazioni, principalmente perché non è immediatamente ovvio che fosse destinato.
Bob Somers,

Sono quasi completamente d'accordo con te. Ho detto che era disperato!
tenpn

3
Ciò non spezzerebbe anche i cortocircuiti e renderebbe la previsione del ramo più inaffidabile?
Egon,

1
Se foo è 2 e bar è 1, il codice non si comporta affatto allo stesso modo. Questa, e non una valutazione precoce, è il più grande aspetto negativo che penso.

1
In verità, i valori booleani in C ++ sono garantiti a 0 o 1, quindi finché lo fai solo con bool sei al sicuro. Altro: altdevblogaday.org/2011/04/18/understanding-your-bool-type
tenpn

1

Tieni d'occhio l'utilizzo del tuo stack

Tutto ciò che aggiungi allo stack è una spinta e una costruzione extra quando viene chiamata una funzione. Quando è richiesta una grande quantità di spazio dello stack, a volte può essere utile allocare memoria di lavoro in anticipo e se la piattaforma su cui si sta lavorando ha una RAM veloce disponibile per l'uso, tanto meglio!

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.