Sei vittima di una previsione del ramo fallita.
Cos'è Branch Prediction?
Prendi in considerazione un incrocio ferroviario:
Immagine di Mecanismo, tramite Wikimedia Commons. Utilizzato sotto la licenza CC-By-SA 3.0 .
Ora, per amor di discussione, supponiamo che ciò risalga al 1800 - prima della lunga distanza o della comunicazione radio.
Sei l'operatore di un incrocio e senti arrivare un treno. Non hai idea di come dovrebbe andare. Fermate il treno per chiedere all'autista quale direzione vogliono. E poi hai impostato l'interruttore in modo appropriato.
I treni sono pesanti e hanno molta inerzia. Quindi impiegano un'eternità per avviarsi e rallentare.
Esiste un modo migliore? Indovina in quale direzione andrà il treno!
- Se hai indovinato, continua.
- Se hai indovinato, il capitano si fermerà, indietreggerà e ti urlerà per premere l'interruttore. Quindi può riavviare l'altro percorso.
Se indovina ogni volta , il treno non dovrà mai fermarsi.
Se indovini troppo spesso , il treno impiegherà molto tempo a fermarsi, fare il backup e riavviare.
Considera un'istruzione if: a livello di processore, è un'istruzione di ramo:
Sei un processore e vedi un ramo. Non hai idea di come andrà. cosa fai? Interrompi l'esecuzione e attendi fino al completamento delle istruzioni precedenti. Quindi prosegui lungo il percorso corretto.
I processori moderni sono complicati e hanno condotte lunghe. Quindi impiegano un'eternità a "riscaldarsi" e "rallentare".
Esiste un modo migliore? Indovina in che direzione andrà il ramo!
- Se hai indovinato, continua l'esecuzione.
- Se hai indovinato, devi svuotare la tubazione e tornare al ramo. Quindi è possibile riavviare l'altro percorso.
Se indovina ogni volta , l'esecuzione non dovrà mai fermarsi.
Se indovini troppo spesso , passi molto tempo a fare lo stallo, il rollback e il riavvio.
Questa è la previsione del ramo. Ammetto che non è la migliore analogia poiché il treno potrebbe semplicemente segnalare la direzione con una bandiera. Ma nei computer, il processore non sa in quale direzione andrà un ramo fino all'ultimo momento.
Quindi, come indovineresti strategicamente di ridurre al minimo il numero di volte in cui il treno deve risalire e scendere sull'altro percorso? Guarda la storia passata! Se il treno parte a sinistra il 99% delle volte, allora indovina a sinistra. Se si alterna, allora si alternano le tue ipotesi. Se va in un modo ogni tre volte, indovina lo stesso ...
In altre parole, si tenta di identificare un modello e seguirlo. Questo è più o meno il modo in cui funzionano i predittori di filiali.
La maggior parte delle applicazioni ha rami ben educati. Quindi i predittori di filiali moderni raggiungono in genere tassi di successo> 90%. Ma di fronte a rami imprevedibili senza schemi riconoscibili, i predittori di rami sono praticamente inutili.
Ulteriori letture: articolo "Branch predictor" su Wikipedia .
Come accennato dall'alto, il colpevole è questa dichiarazione if:
if (data[c] >= 128)
sum += data[c];
Si noti che i dati vengono distribuiti uniformemente tra 0 e 255. Quando i dati vengono ordinati, all'incirca la prima metà delle iterazioni non inserirà l'istruzione if. Dopodiché, inseriranno tutti l'istruzione if.
Questo è molto amichevole per il predittore di succursale poiché il ramo segue consecutivamente la stessa direzione molte volte. Anche un semplice contatore di saturazione predirà correttamente il ramo tranne le poche iterazioni dopo che cambia direzione.
Visualizzazione rapida:
T = branch taken
N = branch not taken
data[] = 0, 1, 2, 3, 4, ... 126, 127, 128, 129, 130, ... 250, 251, 252, ...
branch = N N N N N ... N N T T T ... T T T ...
= NNNNNNNNNNNN ... NNNNNNNTTTTTTTTT ... TTTTTTTTTT (easy to predict)
Tuttavia, quando i dati sono completamente casuali, il predittore di filiali viene reso inutile, poiché non è in grado di prevedere dati casuali. Quindi ci sarà probabilmente un errore di circa il 50% (niente di meglio di un'ipotesi casuale).
data[] = 226, 185, 125, 158, 198, 144, 217, 79, 202, 118, 14, 150, 177, 182, 133, ...
branch = T, T, N, T, T, T, T, N, T, N, N, T, T, T, N ...
= TTNTTTTNTNNTTTN ... (completely random - hard to predict)
Quindi cosa si può fare?
Se il compilatore non è in grado di ottimizzare il ramo in una mossa condizionale, puoi provare alcuni hack se sei disposto a sacrificare la leggibilità per le prestazioni.
Sostituire:
if (data[c] >= 128)
sum += data[c];
con:
int t = (data[c] - 128) >> 31;
sum += ~t & data[c];
Questo elimina il ramo e lo sostituisce con alcune operazioni bit a bit.
(Nota che questo hack non è strettamente equivalente all'istruzione if originale. Ma in questo caso, è valido per tutti i valori di input di data[]
.)
Benchmark: Core i7 920 a 3,5 GHz
C ++ - Visual Studio 2010 - Versione x64
// Branch - Random
seconds = 11.777
// Branch - Sorted
seconds = 2.352
// Branchless - Random
seconds = 2.564
// Branchless - Sorted
seconds = 2.587
Java - NetBeans 7.1.1 JDK 7 - x64
// Branch - Random
seconds = 10.93293813
// Branch - Sorted
seconds = 5.643797077
// Branchless - Random
seconds = 3.113581453
// Branchless - Sorted
seconds = 3.186068823
osservazioni:
- Con la filiale: esiste un'enorme differenza tra i dati ordinati e non ordinati.
- Con l'hack: non c'è differenza tra dati ordinati e non ordinati.
- Nel caso C ++, l'hacking è in realtà un po 'più lento rispetto al ramo quando i dati vengono ordinati.
Una regola empirica generale è quella di evitare la ramificazione dipendente dai dati nei loop critici (come in questo esempio).
Aggiornare:
GCC 4.6.1 con -O3
o -ftree-vectorize
su x64 è in grado di generare uno spostamento condizionato. Quindi non vi è alcuna differenza tra i dati ordinati e non ordinati: entrambi sono veloci.
(O in qualche modo veloce: per il caso già risolto, cmov
può essere più lento soprattutto se GCC lo mette sul percorso critico anziché solo add
, specialmente su Intel prima di Broadwell dove cmov
ha una latenza di 2 cicli: flag di ottimizzazione gcc -O3 rende il codice più lento di -O2 )
VC ++ 2010 non è in grado di generare mosse condizionate per questo ramo anche sotto /Ox
.
Intel C ++ Compiler (ICC) 11 fa qualcosa di miracoloso. Esso scambia i due cicli di sollevamento così il ramo imprevedibile all'anello esterno. Quindi non solo è immune alle previsioni errate, ma è anche due volte più veloce di qualsiasi VC ++ e GCC possano generare! In altre parole, ICC ha approfittato del test-loop per sconfiggere il benchmark ...
Se dai al compilatore Intel il codice branchless, lo vettorializza da solo ... ed è veloce come con il branch (con l'interscambio loop).
Questo dimostra che anche i compilatori moderni e maturi possono variare notevolmente nella loro capacità di ottimizzare il codice ...