Perché l'elaborazione di un array ordinato è più veloce dell'elaborazione di un array non ordinato?


24453

Ecco un pezzo di codice C ++ che mostra alcuni comportamenti molto particolari. Per qualche strana ragione, l'ordinamento miracoloso dei dati rende il codice quasi sei volte più veloce:

#include <algorithm>
#include <ctime>
#include <iostream>

int main()
{
    // Generate data
    const unsigned arraySize = 32768;
    int data[arraySize];

    for (unsigned c = 0; c < arraySize; ++c)
        data[c] = std::rand() % 256;

    // !!! With this, the next loop runs faster.
    std::sort(data, data + arraySize);

    // Test
    clock_t start = clock();
    long long sum = 0;

    for (unsigned i = 0; i < 100000; ++i)
    {
        // Primary loop
        for (unsigned c = 0; c < arraySize; ++c)
        {
            if (data[c] >= 128)
                sum += data[c];
        }
    }

    double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;

    std::cout << elapsedTime << std::endl;
    std::cout << "sum = " << sum << std::endl;
}
  • Senza std::sort(data, data + arraySize);, il codice viene eseguito in 11,54 secondi.
  • Con i dati ordinati, il codice viene eseguito in 1,93 secondi.

Inizialmente, ho pensato che potesse trattarsi solo di un'anomalia del compilatore o del linguaggio, quindi ho provato Java:

import java.util.Arrays;
import java.util.Random;

public class Main
{
    public static void main(String[] args)
    {
        // Generate data
        int arraySize = 32768;
        int data[] = new int[arraySize];

        Random rnd = new Random(0);
        for (int c = 0; c < arraySize; ++c)
            data[c] = rnd.nextInt() % 256;

        // !!! With this, the next loop runs faster
        Arrays.sort(data);

        // Test
        long start = System.nanoTime();
        long sum = 0;

        for (int i = 0; i < 100000; ++i)
        {
            // Primary loop
            for (int c = 0; c < arraySize; ++c)
            {
                if (data[c] >= 128)
                    sum += data[c];
            }
        }

        System.out.println((System.nanoTime() - start) / 1000000000.0);
        System.out.println("sum = " + sum);
    }
}

Con un risultato simile ma meno estremo.


Il mio primo pensiero è stato che l'ordinamento porta i dati nella cache, ma poi ho pensato a quanto fosse sciocco perché l'array è stato appena generato.

  • Cosa sta succedendo?
  • Perché l'elaborazione di un array ordinato è più veloce dell'elaborazione di un array non ordinato?

Il codice sta riassumendo alcuni termini indipendenti, quindi l'ordine non dovrebbe avere importanza.



16
@SachinVerma In cima alla mia testa: 1) La JVM potrebbe essere finalmente abbastanza intelligente da usare le mosse condizionate. 2) Il codice è associato alla memoria. 200M è troppo grande per adattarsi alla cache della CPU. Quindi le prestazioni saranno strozzate dalla larghezza di banda della memoria anziché dalla ramificazione.
Mistico il

12
@ Mysticial, circa 2). Ho pensato che la tabella di previsione tenesse traccia dei modelli (indipendentemente dalle variabili effettive verificate per quel modello) e modificasse l'output della previsione in base alla cronologia. Potrebbe per favore darmi una ragione, perché un array super grande non trarrebbe beneficio dalla previsione del ramo?
Sachin Verma,

15
@SachinVerma Lo fa, ma quando l'array è così grande, probabilmente entra in gioco un fattore ancora più grande: la larghezza di banda della memoria. La memoria non è scarica . L'accesso alla memoria è molto lento e la larghezza di banda è limitata. Per semplificare eccessivamente le cose, ci sono solo così tanti byte che possono essere trasferiti tra CPU e memoria in un determinato periodo di tempo. Un codice semplice come quello in questa domanda probabilmente colpirà quel limite anche se è rallentato da previsioni errate. Ciò non accade con un array di 32768 (128 KB) perché si adatta alla cache L2 della CPU.
Mistico il

13
C'è un nuovo difetto di sicurezza chiamato BranchScope: cs.ucr.edu/~nael/pubs/asplos18.pdf
Veve

Risposte:


31800

Sei vittima di una previsione del ramo fallita.


Cos'è Branch Prediction?

Prendi in considerazione un incrocio ferroviario:

Immagine che mostra 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:

Schermata del codice compilato contenente un'istruzione if

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 -O3o -ftree-vectorizesu 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, cmovpuò essere più lento soprattutto se GCC lo mette sul percorso critico anziché solo add, specialmente su Intel prima di Broadwell dove cmovha 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 ...


256
Dai un'occhiata a questa domanda di follow-up: stackoverflow.com/questions/11276291/… Il compilatore Intel è arrivato abbastanza vicino a sbarazzarsi completamente del loop esterno.
Mistico il

24
@Mysticial Come fa il treno / compilatore a sapere che ha inserito la strada sbagliata?
onmyway133

26
@obe: date le strutture di memoria gerarchiche, è impossibile dire quale sarà la spesa di una cache mancata. Potrebbe mancare in L1 ed essere risolto in L2 più lento, oppure mancare in L3 ed essere risolto nella memoria di sistema. Tuttavia, a meno che per qualche bizzarra ragione questa mancanza di cache causi il caricamento della memoria in una pagina non residente dal disco, hai un buon punto ... la memoria non ha avuto il tempo di accesso nell'intervallo di millisecondi in circa 25-30 anni ;)
Andon M. Coleman,

21
Regola empirica per la scrittura di codice efficiente su un moderno processore: tutto ciò che rende l'esecuzione del programma più regolare (meno irregolare) tenderà a renderlo più efficiente. L'ordinamento in questo esempio ha questo effetto a causa della previsione del ramo. La località di accesso (anziché gli accessi casuali in lungo e in largo) ha questo effetto a causa delle cache.
Lutz Prechelt,

22
@Sandeep Sì. I processori hanno ancora la previsione del ramo. Se qualcosa è cambiato, sono i compilatori. Oggi scommetto che sono più propensi a fare ciò che ICC e GCC (sotto -O3) hanno fatto qui - cioè rimuovere il ramo. Dato l'alto profilo di questa domanda, è molto probabile che i compilatori siano stati aggiornati per gestire specificamente il caso in questa domanda. Sicuramente presta attenzione a SO. Ed è successo su questa domanda in cui GCC è stato aggiornato entro 3 settimane. Non vedo perché non accada anche qui.
Mistico

4087

Predizione del ramo.

Con una matrice ordinata, la condizione data[c] >= 128è prima falseper una serie di valori, quindi diventa trueper tutti i valori successivi. È facile da prevedere. Con una matrice non ordinata, si paga il costo di ramificazione.


105
La previsione dei rami funziona meglio su array ordinati rispetto a array con modelli diversi? Ad esempio, per l'array -> {10, 5, 20, 10, 40, 20, ...} l'elemento successivo nell'array dal modello è 80. Questo tipo di array verrebbe accelerato dalla previsione del ramo in quale è il prossimo elemento 80 qui se lo schema è seguito? O di solito aiuta solo con le matrici ordinate?
Adam Freeman,

133
Quindi praticamente tutto ciò che ho imparato convenzionalmente su big-O è fuori dalla finestra? Meglio sostenere un costo di smistamento che un costo di ramificazione?
Agrim Pathak,

133
@AgrimPathak Dipende. Per input non troppo grandi, un algoritmo con maggiore complessità è più veloce di un algoritmo con minore complessità quando le costanti sono più piccole per l'algoritmo con maggiore complessità. Dove è il punto di pareggio può essere difficile da prevedere. Inoltre, confronta questo , la località è importante. Big-O è importante, ma non è l'unico criterio per le prestazioni.
Daniel Fischer,

65
Quando viene eseguita la previsione del ramo? Quando la lingua saprà che l'array è ordinato? Sto pensando a una situazione di array che assomiglia a: [1,2,3,4,5, ... 998.999,1000, 3, 10001, 10002]? questo oscuro 3 aumenterà il tempo di esecuzione? Sarà lungo quanto l'array non ordinato?
Filip Bartuzi,

63
La previsione di @FilipBartuzi Branch si svolge nel processore, al di sotto del livello della lingua (ma la lingua può offrire modi per dire al compilatore che cosa è probabile, quindi il compilatore può emettere codice adatto a quello). Nel tuo esempio, il fuori servizio 3 comporterà un errore di filiale (per condizioni appropriate, in cui 3 fornisce un risultato diverso da 1000), e quindi l'elaborazione di tale array richiederà probabilmente una dozzina o centinaia di nanosecondi più lunghi di un matrice ordinata sarebbe quasi mai evidente. Ciò che costa tempo è l'alto tasso di previsioni errate, un errore per 1000 non è molto.
Daniel Fischer,

3312

Il motivo per cui le prestazioni migliorano drasticamente quando i dati vengono ordinati è che la penalità di previsione del ramo viene rimossa, come spiegato magnificamente nella risposta di Mysticial .

Ora, se guardiamo il codice

if (data[c] >= 128)
    sum += data[c];

possiamo scoprire che il significato di questo particolare if... else...ramo è aggiungere qualcosa quando una condizione è soddisfatta. Questo tipo di ramo può essere facilmente trasformato in un'istruzione di movimento condizionale , che verrebbe compilata in un'istruzione di movimento condizionale:, cmovlin un x86sistema. Il ramo e quindi la potenziale penalità di previsione del ramo viene rimosso.

In C, in tal modo C++, l'affermazione, che sarebbe compilare direttamente (senza alcuna ottimizzazione) nel l'istruzione di movimento condizionale x86, è l'operatore ternario ... ? ... : .... Quindi riscriviamo la precedente affermazione in una equivalente:

sum += data[c] >=128 ? data[c] : 0;

Pur mantenendo la leggibilità, possiamo verificare il fattore di accelerazione.

Su un Intel Core i7 -2600K a 3,4 GHz e Visual Studio 2010 Release Mode, il benchmark è (formato copiato da Mysticial):

X 86

//  Branch - Random
seconds = 8.885

//  Branch - Sorted
seconds = 1.528

//  Branchless - Random
seconds = 3.716

//  Branchless - Sorted
seconds = 3.71

x64

//  Branch - Random
seconds = 11.302

//  Branch - Sorted
 seconds = 1.830

//  Branchless - Random
seconds = 2.736

//  Branchless - Sorted
seconds = 2.737

Il risultato è solido in più test. Otteniamo un grande aumento di velocità quando il risultato del ramo è imprevedibile, ma soffriamo un po 'quando è prevedibile. In effetti, quando si utilizza uno spostamento condizionato, le prestazioni sono le stesse indipendentemente dal modello di dati.

Ora guardiamo più da vicino investigando l' x86assemblaggio che generano. Per semplicità, usiamo due funzioni max1e max2.

max1usa il ramo condizionale if... else ...:

int max1(int a, int b) {
    if (a > b)
        return a;
    else
        return b;
}

max2utilizza l'operatore ternario ... ? ... : ...:

int max2(int a, int b) {
    return a > b ? a : b;
}

Su una macchina x86-64, GCC -Sgenera l'assemblaggio di seguito.

:max1
    movl    %edi, -4(%rbp)
    movl    %esi, -8(%rbp)
    movl    -4(%rbp), %eax
    cmpl    -8(%rbp), %eax
    jle     .L2
    movl    -4(%rbp), %eax
    movl    %eax, -12(%rbp)
    jmp     .L4
.L2:
    movl    -8(%rbp), %eax
    movl    %eax, -12(%rbp)
.L4:
    movl    -12(%rbp), %eax
    leave
    ret

:max2
    movl    %edi, -4(%rbp)
    movl    %esi, -8(%rbp)
    movl    -4(%rbp), %eax
    cmpl    %eax, -8(%rbp)
    cmovge  -8(%rbp), %eax
    leave
    ret

max2usa molto meno codice a causa dell'uso delle istruzioni cmovge. Ma il vero guadagno è che max2non comporta salti di diramazione jmp, il che avrebbe una penalità di prestazione significativa se il risultato previsto non fosse giusto.

Quindi perché una mossa condizionale funziona meglio?

In un tipico x86processore, l'esecuzione di un'istruzione è divisa in più fasi. All'incirca, abbiamo hardware diverso per gestire diverse fasi. Quindi non dobbiamo aspettare che un'istruzione finisca per avviarne una nuova. Questo si chiama pipelining .

In un caso derivato, la seguente istruzione è determinata dalla precedente, quindi non è possibile eseguire il pipelining. Dobbiamo aspettare o prevedere.

In un caso di mossa condizionale, l'istruzione di mossa condizionale di esecuzione è divisa in più fasi, ma le fasi precedenti gradiscono Fetche Decodenon dipendono dal risultato dell'istruzione precedente; solo gli ultimi stadi hanno bisogno del risultato. Pertanto, attendiamo una frazione del tempo di esecuzione di un'istruzione. Questo è il motivo per cui la versione di spostamento condizionale è più lenta del ramo quando la previsione è semplice.

Il libro Computer Systems: A Programmer's Perspective, la seconda edizione lo spiega in dettaglio. È possibile consultare la Sezione 3.6.6 per le istruzioni di spostamento condizionale , l'intero Capitolo 4 per l' architettura del processore e la Sezione 5.11.2 per un trattamento speciale per la previsione di succursali e le penalità di errore .

A volte, alcuni compilatori moderni possono ottimizzare il nostro codice per l'assemblaggio con prestazioni migliori, a volte alcuni compilatori non possono (il codice in questione utilizza il compilatore nativo di Visual Studio). Conoscere la differenza di prestazioni tra ramo e spostamento condizionale quando imprevedibile può aiutarci a scrivere codice con prestazioni migliori quando lo scenario diventa così complesso che il compilatore non può ottimizzarlo automaticamente.


7
@ BlueRaja-DannyPflughoeft Questa è la versione non ottimizzata. Il compilatore NON ha ottimizzato l'operatore ternario, lo ha solo TRADOTTO. GCC può ottimizzare if-then se viene fornito un livello di ottimizzazione sufficiente, tuttavia, questo mostra la potenza del movimento condizionale e l'ottimizzazione manuale fa la differenza.
WiSaGaN,

100
@WiSaGaN Il codice non mostra nulla, perché i due pezzi di codice vengono compilati nello stesso codice macchina. È di fondamentale importanza che le persone non abbiano l'idea che in qualche modo l'istruzione if nel tuo esempio sia diversa dalla terenaria nel tuo esempio. È vero che possiedi la somiglianza nel tuo ultimo paragrafo, ma ciò non cancella il fatto che il resto dell'esempio sia dannoso.
Justin L.,

55
@WiSaGaN Il mio downvote si trasformerebbe sicuramente in un voto se modificassi la tua risposta per rimuovere l' -O0esempio fuorviante e per mostrare la differenza nell'asm ottimizzato sui tuoi due test.
Justin L.,

56
@UpAndAdam Al momento del test, VS2010 non può ottimizzare il ramo originale in una mossa condizionale anche quando si specifica un livello di ottimizzazione elevato, mentre gcc può farlo.
WiSaGaN

9
Questo trucco dell'operatore ternario funziona magnificamente per Java. Dopo aver letto la risposta di Mystical, mi chiedevo cosa si potesse fare per Java per evitare la previsione di falsi rami poiché Java non ha nulla di equivalente a -O3. operatore ternario: 2.1943s e originale: 6.0303s.
Kin Cheung,

2272

Se sei curioso di sapere ancora più ottimizzazioni che è possibile fare con questo codice, considera questo:

A partire dal loop originale:

for (unsigned i = 0; i < 100000; ++i)
{
    for (unsigned j = 0; j < arraySize; ++j)
    {
        if (data[j] >= 128)
            sum += data[j];
    }
}

Con l'interscambio loop, possiamo tranquillamente cambiare questo loop in:

for (unsigned j = 0; j < arraySize; ++j)
{
    for (unsigned i = 0; i < 100000; ++i)
    {
        if (data[j] >= 128)
            sum += data[j];
    }
}

Quindi, puoi vedere che il ifcondizionale è costante durante l'esecuzione del iciclo, quindi puoi sollevare il iffuori:

for (unsigned j = 0; j < arraySize; ++j)
{
    if (data[j] >= 128)
    {
        for (unsigned i = 0; i < 100000; ++i)
        {
            sum += data[j];
        }
    }
}

Quindi, vedi che il ciclo interno può essere compresso in una singola espressione, supponendo che il modello in virgola mobile lo consenta ( /fp:fastviene gettato, ad esempio)

for (unsigned j = 0; j < arraySize; ++j)
{
    if (data[j] >= 128)
    {
        sum += data[j] * 100000;
    }
}

Quello è 100.000 volte più veloce di prima.


276
Se vuoi imbrogliare, potresti anche prendere la moltiplicazione al di fuori del ciclo e fare somma * = 100000 dopo il ciclo.
Jyaif,

78
@Michael - Credo che questo esempio sia in realtà un esempio di ottimizzazione del sollevamento invariante di loop (LIH) e di scambio di loop NOT . In questo caso, l'intero anello interno è indipendente idall'anello esterno e può quindi essere sollevato dall'anello esterno, per cui il risultato viene semplicemente moltiplicato per una somma di un'unità = 1e5. Non fa alcuna differenza per il risultato finale, ma volevo solo mettere le cose in chiaro poiché si tratta di una pagina così frequentata.
Yair Altman

54
Sebbene non sia nel semplice spirito di scambiare i loop, l'interno ifa questo punto potrebbe essere convertito in: sum += (data[j] >= 128) ? data[j] * 100000 : 0;che il compilatore potrebbe essere in grado di ridurre cmovgeo equivalente.
Alex North-Keys,

43
L'anello esterno serve a rendere il tempo impiegato dall'anello interno abbastanza grande da essere profilato. Quindi perché dovresti ripetere lo swap. Alla fine, quel loop verrà rimosso comunque.
saurabheights,

34
@saurabheights: Domanda sbagliata: perché il compilatore NON dovrebbe scambiare loop. Microbenchmarks is hard;)
Matthieu M.

1885

Senza dubbio alcuni di noi sarebbero interessati ai modi di identificare il codice che è problematico per il predittore di filiali della CPU. Lo strumento Valgrind cachegrindha un simulatore predittore di rami, abilitato usando il --branch-sim=yesflag. Eseguendolo sugli esempi in questa domanda, con il numero di loop esterni ridotto a 10000 e compilato g++, si ottengono questi risultati:

Smistato:

==32551== Branches:        656,645,130  (  656,609,208 cond +    35,922 ind)
==32551== Mispredicts:         169,556  (      169,095 cond +       461 ind)
==32551== Mispred rate:            0.0% (          0.0%     +       1.2%   )

indifferenziati:

==32555== Branches:        655,996,082  (  655,960,160 cond +  35,922 ind)
==32555== Mispredicts:     164,073,152  (  164,072,692 cond +     460 ind)
==32555== Mispred rate:           25.0% (         25.0%     +     1.2%   )

Eseguendo il drill-down nell'output riga per riga prodotta da cg_annotatevediamo per il loop in questione:

Smistato:

          Bc    Bcm Bi Bim
      10,001      4  0   0      for (unsigned i = 0; i < 10000; ++i)
           .      .  .   .      {
           .      .  .   .          // primary loop
 327,690,000 10,016  0   0          for (unsigned c = 0; c < arraySize; ++c)
           .      .  .   .          {
 327,680,000 10,006  0   0              if (data[c] >= 128)
           0      0  0   0                  sum += data[c];
           .      .  .   .          }
           .      .  .   .      }

indifferenziati:

          Bc         Bcm Bi Bim
      10,001           4  0   0      for (unsigned i = 0; i < 10000; ++i)
           .           .  .   .      {
           .           .  .   .          // primary loop
 327,690,000      10,038  0   0          for (unsigned c = 0; c < arraySize; ++c)
           .           .  .   .          {
 327,680,000 164,050,007  0   0              if (data[c] >= 128)
           0           0  0   0                  sum += data[c];
           .           .  .   .          }
           .           .  .   .      }

Ciò consente di identificare facilmente la linea problematica: nella versione non ordinata la if (data[c] >= 128)linea sta causando 164.050.007 rami condizionali non Bcmprevisti ( ) nel modello predittore di ramo di cachegrind, mentre sta causando solo 10.006 nella versione ordinata.


In alternativa, su Linux è possibile utilizzare il sottosistema contatori delle prestazioni per eseguire la stessa attività, ma con prestazioni native utilizzando i contatori CPU.

perf stat ./sumtest_sorted

Smistato:

 Performance counter stats for './sumtest_sorted':

  11808.095776 task-clock                #    0.998 CPUs utilized          
         1,062 context-switches          #    0.090 K/sec                  
            14 CPU-migrations            #    0.001 K/sec                  
           337 page-faults               #    0.029 K/sec                  
26,487,882,764 cycles                    #    2.243 GHz                    
41,025,654,322 instructions              #    1.55  insns per cycle        
 6,558,871,379 branches                  #  555.455 M/sec                  
       567,204 branch-misses             #    0.01% of all branches        

  11.827228330 seconds time elapsed

indifferenziati:

 Performance counter stats for './sumtest_unsorted':

  28877.954344 task-clock                #    0.998 CPUs utilized          
         2,584 context-switches          #    0.089 K/sec                  
            18 CPU-migrations            #    0.001 K/sec                  
           335 page-faults               #    0.012 K/sec                  
65,076,127,595 cycles                    #    2.253 GHz                    
41,032,528,741 instructions              #    0.63  insns per cycle        
 6,560,579,013 branches                  #  227.183 M/sec                  
 1,646,394,749 branch-misses             #   25.10% of all branches        

  28.935500947 seconds time elapsed

Può anche eseguire l'annotazione del codice sorgente con lo smontaggio.

perf record -e branch-misses ./sumtest_unsorted
perf annotate -d sumtest_unsorted
 Percent |      Source code & Disassembly of sumtest_unsorted
------------------------------------------------
...
         :                      sum += data[c];
    0.00 :        400a1a:       mov    -0x14(%rbp),%eax
   39.97 :        400a1d:       mov    %eax,%eax
    5.31 :        400a1f:       mov    -0x20040(%rbp,%rax,4),%eax
    4.60 :        400a26:       cltq   
    0.00 :        400a28:       add    %rax,-0x30(%rbp)
...

Vedi il tutorial sulle prestazioni per maggiori dettagli.


74
Questo è spaventoso, nell'elenco non ordinato, ci dovrebbe essere il 50% di probabilità di colpire l'aggiunta. In qualche modo la previsione delle filiali ha solo un tasso di miss del 25%, come può fare meglio del 50% di miss?
TallBrian,

128
@ tall.b.lo: il 25% è di tutti i rami - ci sono due rami nel ciclo, uno per data[c] >= 128(che ha un tasso di perdita del 50% come suggerisci) e uno per la condizione del ciclo c < arraySizeche ha un tasso di perdita del ~ 0% .
caf

1341

Ho appena letto su questa domanda e le sue risposte e sento che manca una risposta.

Un modo comune per eliminare la previsione del ramo che ho trovato particolarmente efficace nelle lingue gestite è la ricerca di una tabella invece di utilizzare un ramo (anche se in questo caso non l'ho testato).

Questo approccio funziona in generale se:

  1. è un piccolo tavolo e probabilmente verrà memorizzato nella cache del processore e
  2. stai eseguendo le cose in un circuito piuttosto stretto e / o il processore può precaricare i dati.

Contesto e perché

Dal punto di vista del processore, la memoria è lenta. Per compensare la differenza di velocità, un paio di cache sono integrate nel processore (cache L1 / L2). Quindi immagina di fare i tuoi bei calcoli e di capire che hai bisogno di un pezzo di memoria. Il processore otterrà la sua operazione di "caricamento" e caricherà il pezzo di memoria nella cache, quindi utilizzerà la cache per eseguire il resto dei calcoli. Poiché la memoria è relativamente lenta, questo "caricamento" rallenta il programma.

Come la previsione del ramo, questo è stato ottimizzato nei processori Pentium: il processore prevede che deve caricare un pezzo di dati e tenta di caricarlo nella cache prima che l'operazione colpisca effettivamente la cache. Come abbiamo già visto, la previsione del ramo a volte va terribilmente sbagliata: nel peggiore dei casi è necessario tornare indietro e attendere effettivamente un caricamento della memoria, che richiederà un'eternità ( in altre parole: la previsione del ramo non riuscita è negativa, un ricordo caricare dopo un fallimento della previsione del ramo è semplicemente orribile! ).

Fortunatamente per noi, se il modello di accesso alla memoria è prevedibile, il processore lo caricherà nella sua cache veloce e tutto andrà bene.

La prima cosa che dobbiamo sapere è ciò che è piccolo ? Sebbene generalmente sia più piccolo, una regola empirica è attenersi alle tabelle di ricerca di dimensioni <= 4096 byte. Come limite superiore: se la tabella di ricerca è maggiore di 64 KB, probabilmente vale la pena ripensarci.

Costruire un tavolo

Quindi abbiamo capito che possiamo creare un tavolino. La prossima cosa da fare è attivare una funzione di ricerca. Le funzioni di ricerca sono in genere piccole funzioni che utilizzano un paio di operazioni di numero intero di base (e, o, xor, maiusc, aggiungi, rimuovi e forse moltiplicano). Vuoi che i tuoi input vengano tradotti dalla funzione di ricerca in una sorta di "chiave unica" nella tua tabella, che poi ti dà semplicemente la risposta di tutto il lavoro che volevi che facesse.

In questo caso:> = 128 significa che possiamo mantenere il valore, <128 significa che ce ne liberiamo. Il modo più semplice per farlo è usare un 'AND': se lo manteniamo, noi AND con 7FFFFFFF; se vogliamo liberarcene, noi E con 0. Notate anche che 128 è una potenza di 2 - quindi possiamo andare avanti e creare una tabella di 32768/128 numeri interi e riempirla con uno zero e un sacco di 7FFFFFFFF di.

Lingue gestite

Potresti chiederti perché questo funziona bene nelle lingue gestite. Dopotutto, le lingue gestite controllano i confini delle matrici con un ramo per assicurarsi di non sbagliare ...

Beh, non esattamente ... :-)

C'è stato un bel po 'di lavoro sull'eliminazione di questo ramo per le lingue gestite. Per esempio:

for (int i = 0; i < array.Length; ++i)
{
   // Use array[i]
}

In questo caso, è ovvio per il compilatore che la condizione al contorno non verrà mai raggiunta. Almeno il compilatore Microsoft JIT (ma mi aspetto che Java faccia cose simili) noterà questo e rimuoverà del tutto il controllo. WOW, questo non significa alcun ramo. Allo stesso modo, affronterà altri casi ovvi.

In caso di problemi con le ricerche nelle lingue gestite, la chiave è aggiungere una & 0x[something]FFFfunzione di ricerca per rendere prevedibile il controllo dei confini e vederlo andare più veloce.

Il risultato di questo caso

// Generate data
int arraySize = 32768;
int[] data = new int[arraySize];

Random random = new Random(0);
for (int c = 0; c < arraySize; ++c)
{
    data[c] = random.Next(256);
}

/*To keep the spirit of the code intact, I'll make a separate lookup table
(I assume we cannot modify 'data' or the number of loops)*/

int[] lookup = new int[256];

for (int c = 0; c < 256; ++c)
{
    lookup[c] = (c >= 128) ? c : 0;
}

// Test
DateTime startTime = System.DateTime.Now;
long sum = 0;

for (int i = 0; i < 100000; ++i)
{
    // Primary loop
    for (int j = 0; j < arraySize; ++j)
    {
        /* Here you basically want to use simple operations - so no
        random branches, but things like &, |, *, -, +, etc. are fine. */
        sum += lookup[data[j]];
    }
}

DateTime endTime = System.DateTime.Now;
Console.WriteLine(endTime - startTime);
Console.WriteLine("sum = " + sum);
Console.ReadLine();

57
Vuoi bypassare il predittore di filiale, perché? È un'ottimizzazione.
Dustin Oprea,

108
Perché nessun ramo è meglio di un ramo :-) In molte situazioni questo è semplicemente molto più veloce ... se stai ottimizzando, vale sicuramente la pena provare. Lo usano anche abbastanza in f.ex. graphics.stanford.edu/~seander/bithacks.html
atlaste

36
In generale, le tabelle di ricerca possono essere veloci, ma hai eseguito i test per questa particolare condizione? Avrai ancora una condizione di ramo nel tuo codice, solo ora viene spostata nella parte di generazione della tabella di ricerca. Non otterresti ancora la tua spinta
perfetta

38
@Zain se vuoi davvero saperlo ... Sì: 15 secondi con il ramo e 10 con la mia versione. Indipendentemente da ciò, è una tecnica utile da conoscere in entrambi i modi.
atlaste,

42
Perché non sum += lookup[data[j]]dove lookupè un array con 256 voci, le prime sono zero e le ultime sono uguali all'indice?
Kris Vandermotten,

1200

Poiché i dati vengono distribuiti tra 0 e 255 quando l'array viene ordinato, circa la prima metà delle iterazioni non entrerà nello ifstato (l' ifistruzione è condivisa di seguito).

if (data[c] >= 128)
    sum += data[c];

La domanda è: cosa rende l'esecuzione dell'istruzione precedente in alcuni casi come nel caso di dati ordinati? Ecco che arriva il "predittore di diramazione". Un predittore di diramazione è un circuito digitale che tenta di indovinare in quale direzione if-then-elseandrà un ramo (ad esempio una struttura) prima che questo sia noto. Lo scopo del predittore di diramazione è migliorare il flusso nella pipeline di istruzioni. I predittori delle filiali svolgono un ruolo fondamentale nel raggiungimento di prestazioni elevate ed efficaci!

Facciamo un po 'di benchmarking per capirlo meglio

Le prestazioni di uno ifstato dipendono dal fatto che la sua condizione abbia un modello prevedibile. Se la condizione è sempre vera o sempre falsa, la logica di predizione del ramo nel processore prenderà il modello. D'altra parte, se il modello è imprevedibile, lo ifstato sarà molto più costoso.

Misuriamo le prestazioni di questo loop con diverse condizioni:

for (int i = 0; i < max; i++)
    if (condition)
        sum++;

Ecco i tempi del loop con diversi schemi true-false:

Condition                Pattern             Time (ms)
-------------------------------------------------------
(i & 0×80000000) == 0    T repeated          322

(i & 0xffffffff) == 0    F repeated          276

(i & 1) == 0             TF alternating      760

(i & 3) == 0             TFFFTFFF           513

(i & 2) == 0             TTFFTTFF           1675

(i & 4) == 0             TTTTFFFFTTTTFFFF   1275

(i & 8) == 0             8T 8F 8T 8F        752

(i & 16) == 0            16T 16F 16T 16F    490

Un modello " cattivo " vero-falso può rendere una ifdichiarazione fino a sei volte più lenta di un modello " buono "! Naturalmente, quale modello è buono e quale è cattivo dipende dalle istruzioni esatte generate dal compilatore e dal processore specifico.

Quindi non vi è alcun dubbio sull'impatto della previsione del ramo sulle prestazioni!


23
@MooingDuck 'Perché non farà differenza - quel valore può essere qualsiasi cosa, ma sarà comunque nei limiti di queste soglie. Quindi perché mostrare un valore casuale quando si conoscono già i limiti? Anche se concordo sul fatto che potresti mostrarne uno per completezza e "solo per diamine".
cst1992,

24
@ cst1992: In questo momento il suo tempismo più lento è TTFFTTFFTTFF, che a mio avviso umano sembra abbastanza prevedibile. Il casuale è intrinsecamente imprevedibile, quindi è del tutto possibile che sia ancora più lento, e quindi al di fuori dei limiti mostrati qui. OTOH, potrebbe essere che TTFFTTFF colpisca perfettamente il caso patologico. Non posso dirlo, dal momento che non ha mostrato i tempi per casuale.
Mooing Duck,

21
@MooingDuck Per un occhio umano, "TTFFTTFFTTFF" è una sequenza prevedibile, ma ciò di cui stiamo parlando qui è il comportamento del predittore di rami incorporato in una CPU. Il predittore di diramazione non è il riconoscimento di modelli di livello AI; è molto semplice. Quando si alternano solo rami non si prevede bene. Nella maggior parte del codice, i rami vanno allo stesso modo quasi sempre; considera un ciclo che viene eseguito mille volte. Il ramo alla fine del ciclo ritorna all'inizio del ciclo 999 volte, quindi la millesima volta fa qualcosa di diverso. Un predittore di rami molto semplice funziona bene, di solito.
Steveha,

18
@steveha: Penso che tu stia facendo ipotesi su come funziona il predittore di diramazioni CPU e non sono d'accordo con quella metodologia. Non so quanto sia avanzato quel predittore di filiali, ma mi sembra di pensare che sia molto più avanzato di te. Probabilmente hai ragione, ma le misure sarebbero sicuramente buone.
Mooing Duck,

5
@steveha: il predittore adattivo a due livelli potrebbe agganciarsi al modello TTFFTTFF senza alcun problema. "Le varianti di questo metodo di previsione sono utilizzate nella maggior parte dei microprocessori moderni". La previsione delle filiali locali e la previsione delle filiali globali si basano su un predittore adattivo a due livelli, e possono farlo anche loro. "La previsione del ramo globale viene utilizzata nei processori AMD e nei processori Atom basati su Intel Pentium M, Core, Core 2 e Silvermont". Inoltre, aggiungere all'elenco il predittore Concord, il predittore ibrido, la previsione di salti indiretti. Il predittore di loop non si blocca, ma raggiunge il 75%. Restano solo 2 che non riescono a bloccarsi
Mooing Duck il

1126

Un modo per evitare errori di previsione dei rami è quello di creare una tabella di ricerca e indicizzarla utilizzando i dati. Stefan de Bruijn ne ha discusso nella sua risposta.

Ma in questo caso, sappiamo che i valori sono nell'intervallo [0, 255] e ci preoccupiamo solo di valori> = 128. Ciò significa che possiamo facilmente estrarre un singolo bit che ci dirà se vogliamo un valore o meno: spostando i dati a destra a 7 bit, ci rimane uno 0 bit o 1 bit e vogliamo aggiungere il valore solo quando abbiamo 1 bit. Chiamiamo questo bit il "bit decisionale".

Usando il valore 0/1 del bit di decisione come indice in un array, possiamo creare un codice che sarà altrettanto veloce sia che i dati vengano ordinati o meno. Il nostro codice aggiungerà sempre un valore, ma quando il bit di decisione è 0, aggiungeremo il valore da qualche parte che non ci interessa. Ecco il codice:

// Test
clock_t start = clock();
long long a[] = {0, 0};
long long sum;

for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        int j = (data[c] >> 7);
        a[j] += data[c];
    }
}

double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;
sum = a[1];

Questo codice spreca metà delle aggiunte ma non ha mai un errore di previsione del ramo. È incredibilmente più veloce su dati casuali rispetto alla versione con un'istruzione if effettiva.

Ma nei miei test, una tabella di ricerca esplicita è stata leggermente più veloce di questa, probabilmente perché l'indicizzazione in una tabella di ricerca era leggermente più veloce dello spostamento dei bit. Questo mostra come il mio codice si configura e usa la tabella di ricerca (chiamata in modo inimmaginabile lutper "Tabella di ricerca" nel codice). Ecco il codice C ++:

// Declare and then fill in the lookup table
int lut[256];
for (unsigned c = 0; c < 256; ++c)
    lut[c] = (c >= 128) ? c : 0;

// Use the lookup table after it is built
for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        sum += lut[data[c]];
    }
}

In questo caso, la tabella di ricerca era di soli 256 byte, quindi si adattava perfettamente in una cache e tutto era veloce. Questa tecnica non funzionerebbe bene se i dati fossero valori a 24 bit e ne volessimo solo la metà ... la tabella di ricerca sarebbe troppo grande per essere pratica. D'altra parte, possiamo combinare le due tecniche mostrate sopra: prima sposta i bit, quindi indicizza una tabella di ricerca. Per un valore a 24 bit che vogliamo solo il valore della metà superiore, potremmo potenzialmente spostare i dati a destra di 12 bit e rimanere con un valore a 12 bit per un indice di tabella. Un indice di tabella a 12 bit implica una tabella di 4096 valori, che potrebbe essere pratico.

La tecnica di indicizzazione in un array, anziché utilizzare ifun'istruzione, può essere utilizzata per decidere quale puntatore utilizzare. Ho visto una libreria che implementava alberi binari e invece di avere due puntatori con nome ( pLefte / pRighto altro) aveva una serie di puntatori di lunghezza 2 e ho usato la tecnica del "bit di decisione" per decidere quale seguire. Ad esempio, anziché:

if (x < node->value)
    node = node->pLeft;
else
    node = node->pRight;

questa libreria farebbe qualcosa del tipo:

i = (x < node->value);
node = node->link[i];

Ecco un link a questo codice: Red Black Trees , Eternally Confuzzled


29
Bene, puoi anche usare il bit direttamente e moltiplicarlo ( data[c]>>7- che è discusso anche da qualche parte qui); Ho intenzionalmente lasciato fuori questa soluzione, ma ovviamente hai ragione. Solo una piccola nota: la regola empirica per le tabelle di ricerca è che se si adatta a 4KB (a causa della memorizzazione nella cache), funzionerà, preferibilmente rendendo la tabella il più piccola possibile. Per le lingue gestite lo spingerei a 64 KB, per linguaggi di basso livello come C ++ e C, probabilmente riconsidererei (questa è solo la mia esperienza). Da allora typeof(int) = 4, proverei a mantenere un massimo di 10 bit.
atlaste,

17
Penso che l'indicizzazione con il valore 0/1 sarà probabilmente più veloce di un numero intero che si moltiplica, ma suppongo che se le prestazioni sono davvero fondamentali dovresti profilarle. Concordo sul fatto che piccole tabelle di ricerca sono essenziali per evitare la pressione della cache, ma chiaramente se si dispone di una cache più grande è possibile cavarsela con una tabella di ricerca più grande, quindi 4KB è più una regola empirica che una regola difficile. Penso che volevi dire sizeof(int) == 4? Ciò sarebbe vero per i 32 bit. Il mio cellulare di due anni ha una cache L1 da 32 KB, quindi potrebbe funzionare anche una tabella di ricerca 4K, soprattutto se i valori di ricerca erano un byte anziché un int.
steveha,

12
Forse mi manca qualcosa ma nel tuo jmetodo uguale a 0 o 1 perché non moltiplichi semplicemente il tuo valore jprima di aggiungerlo invece di utilizzare l'indicizzazione dell'array (probabilmente dovresti moltiplicarlo per 1-janziché j)
Richard Tingle

6
@steveha La moltiplicazione dovrebbe essere più veloce, ho provato a cercarlo nei libri di Intel, ma non sono riuscito a trovarlo ... in entrambi i casi, il benchmarking mi dà anche questo risultato qui.
atlaste,

10
@steveha PS: un'altra possibile risposta sarebbe int c = data[j]; sum += c & -(c >> 7);che non richiede alcuna moltiplicazione.
atlaste,

1022

Nel caso ordinato, puoi fare di meglio che fare affidamento su una previsione del ramo riuscita o su qualsiasi trucco comparativo senza ramo: rimuovi completamente il ramo.

In effetti, l'array è partizionato in una zona contigua con data < 128e un'altra con data >= 128. Quindi dovresti trovare il punto di partizione con una ricerca dicotomica (usando i Lg(arraySize) = 15confronti), quindi fare un accumulo diretto da quel punto.

Qualcosa di simile (non selezionato)

int i= 0, j, k= arraySize;
while (i < k)
{
  j= (i + k) >> 1;
  if (data[j] >= 128)
    k= j;
  else
    i= j;
}
sum= 0;
for (; i < arraySize; i++)
  sum+= data[i];

o, leggermente più offuscato

int i, k, j= (i + k) >> 1;
for (i= 0, k= arraySize; i < k; (data[j] >= 128 ? k : i)= j)
  j= (i + k) >> 1;
for (sum= 0; i < arraySize; i++)
  sum+= data[i];

Un approccio ancora più veloce, che offre una soluzione approssimativa sia per gli ordinamenti che per gli ordinamenti, è: sum= 3137536;(ipotizzando una distribuzione veramente uniforme, 16384 campioni con valore atteso 191,5) :-)


23
sum= 3137536- intelligente. Questo non è ovviamente il punto della domanda. La domanda riguarda chiaramente la spiegazione di sorprendenti caratteristiche prestazionali. Sono propenso a dire che l'aggiunta di fare std::partitioninvece di std::sortè preziosa. Anche se la vera domanda va oltre il semplice riferimento sintetico.
visto il

12
@DeadMG: questa non è in effetti la ricerca dicotomica standard di una determinata chiave, ma una ricerca dell'indice di partizionamento; richiede un solo confronto per iterazione. Ma non fare affidamento su questo codice, non l'ho verificato. Se sei interessato a un'implementazione corretta garantita, fammelo sapere.
Yves Daoust,

832

Il comportamento sopra sta accadendo a causa della previsione Branch.

Per comprendere la previsione del ramo, è necessario innanzitutto comprendere la pipeline di istruzioni :

Ogni istruzione è suddivisa in una sequenza di passaggi in modo che diversi passaggi possano essere eseguiti contemporaneamente in parallelo. Questa tecnica è nota come pipeline di istruzioni e viene utilizzata per aumentare la produttività nei processori moderni. Per capirlo meglio, vedi questo esempio su Wikipedia .

Generalmente, i processori moderni hanno condutture piuttosto lunghe, ma per semplicità consideriamo solo questi 4 passaggi.

  1. IF: recupera le istruzioni dalla memoria
  2. ID: decodifica l'istruzione
  3. EX - Esegue l'istruzione
  4. WB: riscrivi nel registro CPU

Pipeline a 4 stadi in generale per 2 istruzioni. Pipeline a 4 stadi in generale

Tornando alla domanda precedente, consideriamo le seguenti istruzioni:

                        A) if (data[c] >= 128)
                                /\
                               /  \
                              /    \
                        true /      \ false
                            /        \
                           /          \
                          /            \
                         /              \
              B) sum += data[c];          C) for loop or print().

Senza la previsione del ramo, si verificherebbe quanto segue:

Per eseguire l'istruzione B o l'istruzione C, il processore dovrà attendere che l'istruzione A non raggiunga lo stadio EX nella pipeline, poiché la decisione di passare all'istruzione B o all'istruzione C dipende dal risultato dell'istruzione A. Quindi la pipeline sarà simile a questo.

quando se la condizione ritorna vera: inserisci qui la descrizione dell'immagine

Se la condizione restituisce false: inserisci qui la descrizione dell'immagine

Come risultato dell'attesa del risultato dell'istruzione A, i cicli CPU totali spesi nel caso precedente (senza previsione del ramo; sia per il vero che per il falso) sono 7.

Allora, qual è la previsione del ramo?

Il predittore di rami proverà a indovinare in che direzione andrà un ramo (una struttura if-then-else) prima che questo sia noto. Non aspetterà che l'istruzione A raggiunga la fase EX della pipeline, ma indovinerà la decisione e andrà a quell'istruzione (B o C nel caso del nostro esempio).

In caso di ipotesi corretta, la pipeline si presenta in questo modo: inserisci qui la descrizione dell'immagine

Se viene successivamente rilevato che l'ipotesi era errata, le istruzioni parzialmente eseguite vengono scartate e la pipeline ricomincia da capo con il ramo corretto, causando un ritardo. Il tempo che viene sprecato in caso di errore di una diramazione è uguale al numero di fasi nella pipeline dalla fase di recupero alla fase di esecuzione. I moderni microprocessori tendono ad avere condutture piuttosto lunghe, quindi il ritardo nell'impedimento errato è compreso tra 10 e 20 cicli di clock. Più lunga è la condotta, maggiore è la necessità di un buon predittore di diramazione .

Nel codice OP, la prima volta che il condizionale, il predittore di diramazione non ha alcuna informazione per basare la previsione, quindi la prima volta sceglierà casualmente l'istruzione successiva. Più avanti nel ciclo for, può basare la previsione sulla cronologia. Per un array ordinato in ordine crescente, ci sono tre possibilità:

  1. Tutti gli elementi sono inferiori a 128
  2. Tutti gli elementi sono maggiori di 128
  3. Alcuni nuovi elementi iniziali sono meno di 128 e successivamente diventano maggiori di 128

Supponiamo che il predittore assumerà sempre il ramo vero alla prima esecuzione.

Quindi, nel primo caso, prenderà sempre il vero ramo poiché storicamente tutte le sue previsioni sono corrette. Nel secondo caso, inizialmente prevede un errore, ma dopo alcune iterazioni, prevede correttamente. Nel terzo caso, inizialmente prevede correttamente fino a quando gli elementi sono inferiori a 128. Dopodiché non riuscirà per un po 'di tempo e si correggerà automaticamente quando vedrà un errore nella previsione del ramo nella storia.

In tutti questi casi l'errore sarà in numero troppo ridotto e, di conseguenza, solo alcune volte sarà necessario scartare le istruzioni parzialmente eseguite e ricominciare con il ramo corretto, con conseguente riduzione dei cicli della CPU.

Ma nel caso di un array casuale non ordinato, la previsione dovrà scartare le istruzioni parzialmente eseguite e ricominciare con il ramo corretto per la maggior parte del tempo e provocare più cicli della CPU rispetto all'array ordinato.


1
come vengono eseguite insieme due istruzioni? questo viene fatto con core CPU separati o l'istruzione pipeline è integrata nel core CPU singolo?
M.kazem Akhgary,

1
@ M.kazemAkhgary È tutto all'interno di un nucleo logico. Se sei interessato, questo è ben descritto per esempio nel Manuale per gli sviluppatori del software Intel
Sergey.quixoticaxis.Ivanov,

728

Una risposta ufficiale verrebbe da

  1. Intel: evitare il costo di un errore di filiale
  2. Intel - Riorganizzazione di filiali e circuiti per prevenire errori di interpretazione
  3. Articoli scientifici - architettura del computer di predizione delle filiali
  4. Libri: JL Hennessy, DA Patterson: architettura del computer: un approccio quantitativo
  5. Articoli su pubblicazioni scientifiche: TY Yeh, YN Patt ha fatto molti di questi sulle previsioni delle filiali.

Puoi anche vedere da questo adorabile diagramma perché il predittore di rami viene confuso.

Diagramma di stato a 2 bit

Ogni elemento nel codice originale è un valore casuale

data[c] = std::rand() % 256;

così il predittore cambierà parte come std::rand()colpo.

D'altra parte, una volta ordinato, il predittore si sposterà dapprima in uno stato fortemente non preso e quando i valori cambieranno al valore elevato, il predittore cambierà in tre passaggi passando da fortemente non preso a fortemente preso.



697

Nella stessa linea (penso che questo non sia stato evidenziato da nessuna risposta) è bene ricordare che a volte (specialmente nei software in cui le prestazioni contano, come nel kernel Linux) è possibile trovare alcune istruzioni if ​​come le seguenti:

if (likely( everything_is_ok ))
{
    /* Do something */
}

o similmente:

if (unlikely(very_improbable_condition))
{
    /* Do something */    
}

Entrambi likely()e unlikely()sono in realtà macro che vengono definite utilizzando qualcosa di simile a GCC__builtin_expect per aiutare il compilatore a inserire il codice di previsione per favorire la condizione tenendo conto delle informazioni fornite dall'utente. GCC supporta altri built-in che potrebbero modificare il comportamento del programma in esecuzione o emettere istruzioni di basso livello come svuotare la cache, ecc. Vedi questa documentazione che passa attraverso i built-in disponibili di GCC.

Normalmente questo tipo di ottimizzazioni si trovano principalmente nelle applicazioni in tempo reale o nei sistemi embedded in cui i tempi di esecuzione sono importanti ed è fondamentale. Ad esempio, se stai verificando qualche condizione di errore che si verifica solo 1/10000000 volte, perché non informare il compilatore in merito? In questo modo, per impostazione predefinita, la previsione del ramo presuppone che la condizione sia falsa.


679

Le operazioni booleane usate di frequente in C ++ producono molti rami nel programma compilato. Se questi rami si trovano all'interno di anelli e sono difficili da prevedere, possono rallentare significativamente l'esecuzione. Le variabili booleane sono memorizzate come numeri interi a 8 bit con il valore 0per falsee1 per true.

Le variabili booleane sono sovradeterminate nel senso che tutti gli operatori che hanno variabili booleane come input controllano se gli input hanno un valore diverso da 0o 1, ma gli operatori che hanno booleani come output non possono produrre altri valori di 0o 1. Ciò rende le operazioni con variabili booleane come input meno efficienti del necessario. Prendi in considerazione un esempio:

bool a, b, c, d;
c = a && b;
d = a || b;

Questo è generalmente implementato dal compilatore nel modo seguente:

bool a, b, c, d;
if (a != 0) {
    if (b != 0) {
        c = 1;
    }
    else {
        goto CFALSE;
    }
}
else {
    CFALSE:
    c = 0;
}
if (a == 0) {
    if (b == 0) {
        d = 0;
    }
    else {
        goto DTRUE;
    }
}
else {
    DTRUE:
    d = 1;
}

Questo codice è tutt'altro che ottimale. Le filiali potrebbero impiegare molto tempo in caso di previsioni errate. Le operazioni booleane possono essere rese molto più efficienti se si sa con certezza che gli operandi non hanno altri valori di 0e 1. Il motivo per cui il compilatore non fa tale ipotesi è che le variabili potrebbero avere altri valori se non sono inizializzate o provengono da fonti sconosciute. Il codice sopra può essere ottimizzato se aed bè stato inizializzato su valori validi o se provengono da operatori che producono output booleano. Il codice ottimizzato è simile al seguente:

char a = 0, b = 1, c, d;
c = a & b;
d = a | b;

charviene utilizzato invece di boolper rendere possibile l'uso degli operatori bit a bit ( &e |) invece degli operatori booleani ( &&e ||). Gli operatori bit a bit sono singole istruzioni che richiedono solo un ciclo di clock. L'operatore OR ( |) funziona anche se ae bhanno valori diversi 0o 1. L'operatore AND ( &) e l'operatore EX ESCLUSIVO ( ^) possono dare risultati incoerenti se gli operandi hanno valori diversi da 0e 1.

~non può essere utilizzato per NOT. Invece, puoi creare un valore booleano NOT su una variabile che è nota per essere 0o 1XOR 'con 1:

bool a, b;
b = !a;

può essere ottimizzato per:

char a = 0, b;
b = a ^ 1;

a && bnon può essere sostituito con a & bif bè un'espressione che non deve essere valutata se aè false( &&non valuterà b, lo &farà). Allo stesso modo, a || bnon può essere sostituito con a | bif bè un'espressione che non deve essere valutata se lo aètrue .

L'uso di operatori bit a bit è più vantaggioso se gli operandi sono variabili che se gli operandi sono confronti:

bool a; double x, y, z;
a = x > y && z < 5.0;

è ottimale nella maggior parte dei casi (a meno che non ci si aspetti che l' &&espressione generi molte previsioni errate sul ramo).


342

Certamente!...

La previsione del ramo rende la logica più lenta, a causa della commutazione che avviene nel tuo codice! È come se stessi percorrendo una strada dritta o una strada con molti tornanti, di sicuro quella dritta verrà eseguita più velocemente! ...

Se l'array viene ordinato, la tua condizione è falsa al primo passaggio data[c] >= 128:, quindi diventa un valore vero per l'intero percorso fino alla fine della strada. Ecco come si arriva alla fine della logica più velocemente. D'altra parte, usando una matrice non ordinata, hai bisogno di molte trasformazioni ed elaborazioni che rendono il tuo codice più lento di sicuro ...

Guarda l'immagine che ho creato per te qui sotto. Quale strada finirà più velocemente?

Predizione del ramo

Quindi, a livello di programmazione, la previsione delle diramazioni rallenta il processo ...

Inoltre, alla fine, è bene sapere che abbiamo due tipi di previsioni di diramazione che ognuna influenzerà il codice in modo diverso:

1. Statico

2. Dinamico

Predizione del ramo

La previsione del ramo statico viene utilizzata dal microprocessore la prima volta che si incontra un ramo condizionale e la previsione del ramo dinamico viene utilizzata per le esecuzioni successive del codice del ramo condizionale.

Al fine di scrivere efficacemente il codice per sfruttare queste regole, quando si scrivono if-else o si scambiano le istruzioni, controllare prima i casi più comuni e lavorare progressivamente fino al meno comune. I loop non richiedono necessariamente alcun ordinamento speciale di codice per la previsione dei rami statici, poiché viene normalmente utilizzata solo la condizione dell'iteratore di loop.


304

Questa domanda ha già risposto in modo eccellente molte volte. Mi piacerebbe comunque attirare l'attenzione del gruppo su un'altra interessante analisi.

Recentemente questo esempio (modificato leggermente) è stato anche usato come modo per dimostrare come un pezzo di codice può essere profilato all'interno del programma stesso su Windows. Lungo la strada, l'autore mostra anche come utilizzare i risultati per determinare dove il codice sta trascorrendo la maggior parte del suo tempo sia nel caso ordinato che non ordinato. Infine, il pezzo mostra anche come utilizzare una funzione poco nota dell'HAL (Hardware Abstraction Layer) per determinare quanta cattiva reputazione si sta verificando nel caso non ordinato.

Il link è qui: http://www.geoffchappell.com/studies/windows/km/ntoskrnl/api/ex/profile/demo.htm


3
Questo è un articolo molto interessante (in effetti, ho appena letto tutto), ma come risponde alla domanda?
Peter Mortensen,

2
@PeterMortensen Sono un po 'confuso dalla tua domanda. Ad esempio, ecco una riga pertinente di quel pezzo: l' When the input is unsorted, all the rest of the loop takes substantial time. But with sorted input, the processor is somehow able to spend not just less time in the body of the loop, meaning the buckets at offsets 0x18 and 0x1C, but vanishingly little time on the mechanism of looping. autore sta cercando di discutere la profilazione nel contesto del codice pubblicato qui e nel processo sta cercando di spiegare perché il caso ordinato è molto più veloce.
ForeverLearning

261

Come già menzionato da altri, ciò che sta dietro al mistero è Branch Predictor .

Non sto cercando di aggiungere qualcosa, ma spiegando il concetto in un altro modo. C'è una breve introduzione sul wiki che contiene testo e diagramma. Mi piace la spiegazione che segue che utilizza un diagramma per elaborare intuitivamente Branch Predictor.

Nell'architettura del computer, un predittore di diramazione è un circuito digitale che tenta di indovinare in quale direzione andrà un ramo (ad es. Una struttura if-then-else) prima che questo sia noto. Lo scopo del predittore di diramazione è migliorare il flusso nella pipeline di istruzioni. I predittori di filiali svolgono un ruolo fondamentale nel raggiungimento di elevate prestazioni efficaci in molte architetture moderne a microprocessore pipeline come x86.

La ramificazione a due vie è di solito implementata con un'istruzione di salto condizionale. Un salto condizionale può essere "non eseguito" e continuare l'esecuzione con il primo ramo di codice che segue immediatamente dopo il salto condizionale, oppure può essere "preso" e saltare in un'altra posizione nella memoria del programma dove si trova il secondo ramo di codice immagazzinato. Non è noto con certezza se verrà eseguito o meno un salto condizionale fino a quando la condizione non sarà stata calcolata e il salto condizionale avrà superato la fase di esecuzione nella pipeline di istruzioni (vedere la figura 1).

Figura 1

Sulla base dello scenario descritto, ho scritto una demo di animazione per mostrare come le istruzioni vengono eseguite in una pipeline in diverse situazioni.

  1. Senza il Predittore Branch.

Senza la previsione del ramo, il processore dovrebbe attendere fino a quando l'istruzione di salto condizionale ha superato la fase di esecuzione prima che l'istruzione successiva possa entrare nella fase di recupero nella pipeline.

L'esempio contiene tre istruzioni e la prima è un'istruzione di salto condizionale. Le ultime due istruzioni possono entrare nella pipeline fino a quando non viene eseguita l'istruzione di salto condizionale.

senza predittore di diramazione

Ci vorranno 9 cicli di clock per completare 3 istruzioni.

  1. Usa Branch Predictor e non fare un salto condizionale. Supponiamo che la previsione non stia facendo il salto condizionale.

inserisci qui la descrizione dell'immagine

Ci vorranno 7 cicli di clock per completare 3 istruzioni.

  1. Usa Branch Predictor e fai un salto condizionale. Supponiamo che la previsione non stia facendo il salto condizionale.

inserisci qui la descrizione dell'immagine

Ci vorranno 9 cicli di clock per completare 3 istruzioni.

Il tempo che viene sprecato in caso di errore di una diramazione è uguale al numero di fasi nella pipeline dalla fase di recupero alla fase di esecuzione. I moderni microprocessori tendono ad avere condutture piuttosto lunghe, quindi il ritardo nell'impedimento errato è compreso tra 10 e 20 cicli di clock. Di conseguenza, allungare una pipeline aumenta la necessità di un predittore di diramazione più avanzato.

Come puoi vedere, sembra che non abbiamo motivo di non utilizzare Branch Predictor.

È una demo piuttosto semplice che chiarisce la parte fondamentale di Branch Predictor. Se quelle gif sono fastidiose, sentiti libero di rimuoverle dalla risposta e i visitatori possono anche ottenere il codice sorgente demo live da BranchPredictorDemo


1
Quasi quanto le animazioni di marketing di Intel, ed erano ossessionati non solo dalla previsione delle filiali ma dall'esecuzione fuori ordine, essendo entrambe le strategie "speculative". Anche la lettura anticipata in memoria e archiviazione (pre-recupero sequenziale nel buffer) è speculativa. Tutto sommato.
mckenzm,

@mckenzm: exec speculativo fuori servizio rende la previsione del ramo ancora più preziosa; oltre a nascondere bolle di recupero / decodifica, la previsione del ramo + exec speculativo rimuove le dipendenze di controllo dalla latenza del percorso critico. Il codice all'interno o dopo un if()blocco può essere eseguito prima che la condizione del ramo sia nota. O per un ciclo di ricerca come strleno memchr, le interazioni possono sovrapporsi. Se fosse necessario attendere che il risultato della corrispondenza o meno fosse noto prima di eseguire una delle iterazioni successive, si verificherebbe un collo di bottiglia al carico della cache + latenza ALU anziché velocità effettiva.
Peter Cordes,

210

Guadagno di previsione del ramo!

È importante capire che il malinteso del ramo non rallenta i programmi. Il costo di una previsione persa è proprio come se non esistesse la previsione della diramazione e tu aspettavi che la valutazione dell'espressione decidesse quale codice eseguire (ulteriore spiegazione nel prossimo paragrafo).

if (expression)
{
    // Run 1
} else {
    // Run 2
}

Ogni volta che c'è un'istruzione if-else\ switch, l'espressione deve essere valutata per determinare quale blocco deve essere eseguito. Nel codice assembly generato dal compilatore, vengono inserite le istruzioni del ramo condizionale .

Un'istruzione di ramo può far sì che un computer inizi a eseguire una sequenza di istruzioni diversa e quindi si discosti dal suo comportamento predefinito di esecuzione delle istruzioni in ordine (ovvero se l'espressione è falsa, il programma salta il codice del ifblocco) a seconda di una condizione, che è la valutazione dell'espressione nel nostro caso.

Detto questo, il compilatore cerca di prevedere il risultato prima di essere effettivamente valutato. Riceverà le istruzioni dal ifblocco e se l'espressione risulta vera, allora meravigliosa! Abbiamo guadagnato il tempo necessario per valutarlo e fatto progressi nel codice; in caso contrario, stiamo eseguendo il codice errato, la pipeline viene svuotata e viene eseguito il blocco corretto.

visualizzazione:

Supponiamo che tu debba scegliere il percorso 1 o il percorso 2. In attesa che il tuo partner controlli la mappa, ti sei fermato a ## e atteso, oppure potresti semplicemente scegliere il percorso 1 e se sei stato fortunato (il percorso 1 è il percorso corretto), quindi fantastico non hai dovuto aspettare che il tuo partner controllasse la mappa (hai risparmiato il tempo che gli ci sarebbe voluto per controllare la mappa), altrimenti tornerai indietro.

Mentre svuotare le condutture è super veloce, oggi vale la pena fare questa scommessa. La previsione di dati ordinati o di dati che cambiano lentamente è sempre più facile e migliore della previsione di cambiamenti rapidi.

 O      Route 1  /-------------------------------
/|\             /
 |  ---------##/
/ \            \
                \
        Route 2  \--------------------------------

Mentre il lavaggio delle condotte è super veloce Non proprio. È veloce rispetto a una cache mancante fino alla DRAM, ma su un moderno x86 ad alte prestazioni (come la famiglia Intel Sandybridge) è circa una dozzina di cicli. Sebbene il recupero rapido gli consenta di evitare di attendere che tutte le istruzioni indipendenti più vecchie raggiungano la pensione prima di iniziare il recupero, perdi comunque molti cicli front-end per un errore. Cosa succede esattamente quando una CPU skylake interpreta male un ramo? . (E ogni ciclo può contenere circa 4 istruzioni di lavoro.) Cattivo per codice ad alta produttività.
Peter Cordes,

153

Su ARM, non è necessario alcun ramo, poiché ogni istruzione ha un campo condizione a 4 bit, che verifica (a costo zero) una delle 16 diverse condizioni diverse che possono sorgere nel registro di stato del processore e se la condizione su un'istruzione è falso, l'istruzione viene saltata. Questo elimina la necessità di rami corti e non ci sarebbe alcun risultato di previsione dei rami per questo algoritmo. Pertanto, la versione ordinata di questo algoritmo funzionerebbe più lentamente della versione non ordinata su ARM, a causa del sovraccarico aggiuntivo di ordinamento.

Il loop interno per questo algoritmo sarebbe simile al seguente nel linguaggio assembly ARM:

MOV R0, #0     // R0 = sum = 0
MOV R1, #0     // R1 = c = 0
ADR R2, data   // R2 = addr of data array (put this instruction outside outer loop)
.inner_loop    // Inner loop branch label
    LDRB R3, [R2, R1]     // R3 = data[c]
    CMP R3, #128          // compare R3 to 128
    ADDGE R0, R0, R3      // if R3 >= 128, then sum += data[c] -- no branch needed!
    ADD R1, R1, #1        // c++
    CMP R1, #arraySize    // compare c to arraySize
    BLT inner_loop        // Branch to inner_loop if c < arraySize

Ma questo fa effettivamente parte di un quadro più ampio:

CMPI codici operativi aggiornano sempre i bit di stato nel Processor Status Register (PSR), perché quello è il loro scopo, ma la maggior parte delle altre istruzioni non toccano il PSR a meno che non si aggiunga un Ssuffisso opzionale all'istruzione, specificando che il PSR deve essere aggiornato in base al risultato dell'istruzione. Proprio come il suffisso della condizione a 4 bit, essere in grado di eseguire istruzioni senza influire sul PSR è un meccanismo che riduce la necessità di filiali su ARM e facilita anche l'invio fuori servizio a livello hardware , perché dopo aver eseguito alcune operazioni X che si aggiorna i bit di stato, successivamente (o in parallelo) è possibile eseguire una serie di altri lavori che esplicitamente non dovrebbero influire sui bit di stato, quindi è possibile verificare lo stato dei bit di stato impostati in precedenza da X.

Il campo di verifica delle condizioni e il campo opzionale "imposta bit di stato" possono essere combinati, ad esempio:

  • ADD R1, R2, R3esegue R1 = R2 + R3senza aggiornare alcun bit di stato.
  • ADDGE R1, R2, R3 esegue la stessa operazione solo se un'istruzione precedente che ha influito sui bit di stato ha comportato una condizione Maggiore o uguale.
  • ADDS R1, R2, R3esegue l'aggiunta e quindi aggiorna il N, Z, Ce Vle bandiere nel registro di stato del processore a seconda che il risultato è stato negativo, Zero, Portato (per l'addizione senza segno), o in overflow (per l'addizione firmato).
  • ADDSGE R1, R2, R3esegue l'aggiunta solo se il GEtest è vero, quindi aggiorna successivamente i bit di stato in base al risultato dell'aggiunta.

La maggior parte delle architetture di processori non ha questa capacità di specificare se i bit di stato debbano essere aggiornati per una determinata operazione, il che può richiedere la scrittura di codice aggiuntivo per salvare e successivamente ripristinare i bit di stato, oppure può richiedere rami aggiuntivi o limitare l'uscita del processore di efficienza di esecuzione dell'ordine: uno degli effetti collaterali della maggior parte delle architetture di set di istruzioni della CPU che aggiorna forzatamente i bit di stato dopo la maggior parte delle istruzioni è che è molto più difficile distinguere quali istruzioni possono essere eseguite in parallelo senza interferire l'una con l'altra. L'aggiornamento dei bit di stato ha effetti collaterali, quindi ha un effetto linearizzante sul codice.La capacità di ARM di mescolare e abbinare test delle condizioni senza diramazioni su qualsiasi istruzione con l'opzione di aggiornare o non aggiornare i bit di stato dopo ogni istruzione è estremamente potente, sia per i programmatori di linguaggio assembly che per i compilatori e produce codice molto efficiente.

Se vi siete mai chiesti perché ARM abbia avuto un così straordinario successo, la brillante efficacia e l'interazione di questi due meccanismi sono una parte importante della storia, perché sono una delle maggiori fonti dell'efficienza dell'architettura ARM. Lo splendore dei designer originali di ARM ISA nel 1983, Steve Furber e Roger (ora Sophie) Wilson, non può essere sopravvalutato.


1
L'altra innovazione di ARM è l'aggiunta del suffisso dell'istruzione S, anch'esso facoltativo su (quasi) tutte le istruzioni, che se assente impedisce alle istruzioni di modificare i bit di stato (ad eccezione dell'istruzione CMP, il cui compito è impostare bit di stato, quindi non ha bisogno del suffisso S). Ciò consente di evitare le istruzioni CMP in molti casi, purché il confronto sia con zero o simile (ad esempio SUBS R0, R0, # 1 imposterà il bit Z (Zero) quando R0 raggiunge lo zero). I condizionali e il suffisso S comportano zero spese generali. È un bellissimo ISA.
Luke Hutchison,

2
Non aggiungere il suffisso S consente di avere diverse istruzioni condizionali in una riga senza preoccuparsi che una di esse possa cambiare i bit di stato, che altrimenti potrebbero avere l'effetto collaterale di saltare il resto delle istruzioni condizionali.
Luke Hutchison,

Si noti che l'OP non include il tempo per ordinare nella loro misurazione. Probabilmente è una perdita complessiva l'ordinamento prima di eseguire anche un ciclo di ramo x86, anche se il caso non ordinato rende il ciclo molto più lento. Ma l'ordinamento di un array di grandi dimensioni richiede molto lavoro.
Peter Cordes,

A proposito, è possibile salvare un'istruzione nel ciclo indicizzando la fine dell'array. Prima del loop, imposta R2 = data + arraySize, quindi inizia con R1 = -arraySize. La parte inferiore del loop diventa adds r1, r1, #1/ bnz inner_loop. I compilatori non usano questa ottimizzazione per qualche motivo: / Ma comunque, l'esecuzione prevista dell'aggiunta non è fondamentalmente diversa in questo caso da ciò che puoi fare con il codice senza rami su altri ISA, come x86 cmov. Anche se non è così bello: il flag di ottimizzazione gcc -O3 rende il codice più lento di -O2
Peter Cordes,

1
(L'esecuzione prevista da ARM non rispetta veramente l'istruzione, quindi puoi persino usarla su carichi o archivi che potrebbero guastarsi, a differenza di x86 cmovcon un operando di origine di memoria. La maggior parte degli ISA, incluso AArch64, hanno solo operazioni di selezione ALU. Pertanto la previsione ARM può essere potente, e utilizzabile in modo più efficiente rispetto al codice branchless sulla maggior parte degli ISA.)
Peter Cordes,

147

Riguarda la previsione del ramo. Che cos'è?

  • Un predittore di diramazione è una delle antiche tecniche di miglioramento delle prestazioni che trova ancora rilevanza nelle architetture moderne. Mentre le semplici tecniche di previsione forniscono una rapida ricerca ed efficienza energetica, soffrono di un alto tasso di errore.

  • D'altra parte, previsioni di ramo complesse - basate su neuroni o varianti della previsione di ramo a due livelli - forniscono una migliore precisione di previsione, ma consumano più potenza e aumentano in modo esponenziale la complessità.

  • Inoltre, nelle complesse tecniche di predizione il tempo impiegato per predire i rami è esso stesso molto elevato - che varia da 2 a 5 cicli - che è paragonabile al tempo di esecuzione dei rami effettivi.

  • La previsione delle filiali è essenzialmente un problema di ottimizzazione (minimizzazione) in cui l'accento è posto sul raggiungimento del tasso di perdita più basso possibile, basso consumo energetico e bassa complessità con risorse minime.

Esistono davvero tre diversi tipi di rami:

Inoltra diramazioni condizionali : in base a una condizione di runtime, il PC (contatore del programma) viene modificato in modo da puntare a un indirizzo inoltro nel flusso di istruzioni.

Diramazioni condizionali all'indietro : il PC viene modificato per puntare all'indietro nel flusso di istruzioni. Il ramo si basa su alcune condizioni, come la ramificazione all'inizio di un ciclo di programma quando un test alla fine del ciclo indica che il ciclo deve essere eseguito nuovamente.

Filiali incondizionate : include salti, chiamate di procedura e ritorni che non hanno condizioni specifiche. Ad esempio, un'istruzione di salto incondizionata potrebbe essere codificata nel linguaggio assembly come semplicemente "jmp" e il flusso di istruzioni deve essere immediatamente indirizzato alla posizione di destinazione indicata dall'istruzione di salto, mentre un salto condizionale che potrebbe essere codificato come "jmpne" reindirizzerebbe il flusso di istruzioni solo se il risultato di un confronto di due valori in precedenti istruzioni di "confronto" mostra che i valori non sono uguali. (Lo schema di indirizzamento segmentato utilizzato dall'architettura x86 aggiunge ulteriore complessità, poiché i salti possono essere "vicini" (all'interno di un segmento) o "lontani" (all'esterno del segmento). Ogni tipo ha effetti diversi sugli algoritmi di previsione dei rami.)

Previsione ramo statico / dinamico : la previsione ramo statico viene utilizzata dal microprocessore la prima volta che si incontra un ramo condizionale e la previsione ramo dinamico viene utilizzata per le esecuzioni successive del codice ramo condizionale.

Riferimenti:


146

Oltre al fatto che la previsione del ramo potrebbe rallentarti, un array ordinato presenta un altro vantaggio:

Puoi avere una condizione di stop invece di controllare semplicemente il valore, in questo modo esegui il loop dei dati rilevanti e ignori il resto.
La previsione del ramo mancherà solo una volta.

 // sort backwards (higher values first), may be in some other part of the code
 std::sort(data, data + arraySize, std::greater<int>());

 for (unsigned c = 0; c < arraySize; ++c) {
       if (data[c] < 128) {
              break;
       }
       sum += data[c];               
 }

1
Giusto, ma il costo di installazione per l'ordinamento dell'array è O (N log N), quindi interrompere in anticipo non ti aiuta se l'unico motivo per cui stai ordinando l'array è quello di poter interrompere in anticipo. Se, tuttavia, hai altri motivi per preordinare l'array, allora sì, questo è prezioso.
Luke Hutchison,

Dipende da quante volte ordinate i dati rispetto a quante volte vi circolano. L'ordinamento in questo esempio è solo un esempio, non deve essere proprio prima del ciclo
Yochai Timmer,

2
Sì, questo è esattamente il punto che ho sottolineato nel mio primo commento :-) Tu dici "La previsione del ramo mancherà solo una volta." Ma non stai contando le mancanze di previsione del ramo O (N log N) all'interno dell'algoritmo di ordinamento, che in realtà è maggiore delle mancate di previsione del ramo O (N) nel caso non ordinato. Quindi dovresti usare tutti i tempi O (log N) dei dati ordinati per andare in pareggio (probabilmente in realtà più vicini a O (10 log N), a seconda dell'algoritmo di ordinamento, ad esempio per quicksort, a causa di errori nella cache - mergesort è più coerente con la cache, quindi per avvicinarsi ai punti O (2 log N) dovresti avvicinarti di più.)
Luke Hutchison,

Un'ottimizzazione significativa sarebbe però quella di fare solo "mezzo quicksort", ordinando solo gli elementi in meno del valore di pivot target di 127 (supponendo che tutto ciò che sia inferiore o uguale al pivot sia ordinato dopo il pivot). Una volta raggiunto il pivot, sommare gli elementi prima del pivot. Ciò verrebbe eseguito nel tempo di avvio di O (N) anziché in O (N log N), anche se ci saranno ancora molti errori di previsione del ramo, probabilmente dell'ordine di O (5 N) basato sui numeri che ho dato prima, poiché è mezzo quicksort.
Luke Hutchison,

132

Le matrici ordinate vengono elaborate più rapidamente di una matrice non ordinata, a causa di un fenomeno chiamato previsione del ramo.

Il predittore di diramazione è un circuito digitale (nell'architettura del computer) che cerca di prevedere in che direzione andrà un ramo, migliorando il flusso nella pipeline di istruzioni. Il circuito / computer prevede il passaggio successivo e lo esegue.

Fare una previsione sbagliata porta a tornare al passaggio precedente e ad eseguire un'altra previsione. Supponendo che la previsione sia corretta, il codice continuerà al passaggio successivo. Una previsione errata comporta la ripetizione dello stesso passaggio, fino a quando si verifica una previsione corretta.

La risposta alla tua domanda è molto semplice.

In una matrice non ordinata, il computer esegue più previsioni, aumentando la possibilità di errori. Considerando che, in una matrice ordinata, il computer fa meno previsioni, riducendo la possibilità di errori. Fare più previsioni richiede più tempo.

Array ordinato: Strada diritta ____________________________________________________________________________________ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT

Matrice non ordinata: strada curva

______   ________
|     |__|

Previsione del ramo: indovinare / prevedere quale strada è dritta e seguirla senza controllare

___________________________________________ Straight road
 |_________________________________________|Longer road

Sebbene entrambe le strade raggiungano la stessa destinazione, la strada diritta è più corta e l'altra è più lunga. Se poi scegli l'altro per errore, non puoi tornare indietro e quindi perderai un po 'di tempo extra se scegli la strada più lunga. Questo è simile a quello che succede nel computer e spero che questo ti abbia aiutato a capire meglio.


Voglio anche citare @Simon_Weaver dai commenti:

Non fa meno previsioni, ma fa meno previsioni errate. Deve ancora prevedere per ogni volta attraverso il ciclo ...


124

Ho provato lo stesso codice con MATLAB 2011b con il mio MacBook Pro (Intel i7, 64 bit, 2,4 GHz) per il seguente codice MATLAB:

% Processing time with Sorted data vs unsorted data
%==========================================================================
% Generate data
arraySize = 32768
sum = 0;
% Generate random integer data from range 0 to 255
data = randi(256, arraySize, 1);


%Sort the data
data1= sort(data); % data1= data  when no sorting done


%Start a stopwatch timer to measure the execution time
tic;

for i=1:100000

    for j=1:arraySize

        if data1(j)>=128
            sum=sum + data1(j);
        end
    end
end

toc;

ExeTimeWithSorting = toc - tic;

I risultati per il codice MATLAB sopra riportato sono i seguenti:

  a: Elapsed time (without sorting) = 3479.880861 seconds.
  b: Elapsed time (with sorting ) = 2377.873098 seconds.

I risultati del codice C come in @GManNickG ottengo:

  a: Elapsed time (without sorting) = 19.8761 sec.
  b: Elapsed time (with sorting ) = 7.37778 sec.

Sulla base di questo, sembra che MATLAB sia quasi 175 volte più lento dell'implementazione C senza ordinamento e 350 volte più lento con l'ordinamento. In altre parole, l'effetto (della previsione del ramo) è 1,46x per l'implementazione MATLAB e 2,7x per l'implementazione C.


7
Solo per completezza, probabilmente non è così che lo implementeresti in Matlab. Scommetto che sarebbe molto più veloce se fatto dopo aver vettorializzato il problema.

1
Matlab esegue la parallelizzazione / vettorializzazione automatica in molte situazioni, ma il problema qui è verificare l'effetto della previsione del ramo. Matlab non è immune in alcun modo!
Shan,

1
Matlab utilizza numeri nativi o un'implementazione specifica di mat mat (quantità infinita di cifre o giù di lì?)
Thorbjørn Ravn Andersen

55

L'assunzione da parte di altre risposte della necessità di ordinare i dati non è corretta.

Il codice seguente non ordina l'intero array, ma solo segmenti di 200 elementi e quindi esegue il più veloce.

L'ordinamento delle sole sezioni k-element completa la pre-elaborazione in tempo lineare O(n), anziché nel O(n.log(n))tempo necessario per ordinare l'intero array.

#include <algorithm>
#include <ctime>
#include <iostream>

int main() {
    int data[32768]; const int l = sizeof data / sizeof data[0];

    for (unsigned c = 0; c < l; ++c)
        data[c] = std::rand() % 256;

    // sort 200-element segments, not the whole array
    for (unsigned c = 0; c + 200 <= l; c += 200)
        std::sort(&data[c], &data[c + 200]);

    clock_t start = clock();
    long long sum = 0;

    for (unsigned i = 0; i < 100000; ++i) {
        for (unsigned c = 0; c < sizeof data / sizeof(int); ++c) {
            if (data[c] >= 128)
                sum += data[c];
        }
    }

    std::cout << static_cast<double>(clock() - start) / CLOCKS_PER_SEC << std::endl;
    std::cout << "sum = " << sum << std::endl;
}

Ciò "dimostra" anche che non ha nulla a che fare con qualsiasi problema algoritmico come l'ordinamento, ed è in effetti una previsione del ramo.


4
Non vedo davvero come questo provi qualcosa? L'unica cosa che hai dimostrato è che "non fare tutto il lavoro di ordinamento dell'intero array richiede meno tempo rispetto all'ordinamento dell'intero array". La tua affermazione che questo "funziona anche più veloce" dipende molto dall'architettura. Vedi la mia risposta su come funziona su ARM. PS potresti rendere più veloce il tuo codice su architetture non ARM inserendo la somma all'interno del loop di blocco di 200 elementi, ordinando in ordine inverso e quindi usando il suggerimento di Yochai Timmer di rompere una volta ottenuto un valore fuori range. In questo modo ogni somma di 200 elementi può essere terminata in anticipo.
Luke Hutchison,

Se vuoi solo implementare l'algoritmo in modo efficiente su dati non ordinati, eseguiresti l'operazione senza ramificazioni (e con SIMD, ad esempio con x86 pcmpgtbper trovare elementi con il loro bit alto impostato, quindi AND per zero elementi più piccoli). Trascorrere qualsiasi momento effettivamente smistando blocchi sarebbe più lento. Una versione senza filiali avrebbe prestazioni indipendenti dai dati, dimostrando anche che il costo derivava da un errore di filiale. O semplicemente usa i contatori delle prestazioni per osservarlo direttamente, come Skylake int_misc.clear_resteer_cycleso int_misc.recovery_cyclesper contare i cicli inattivi del front-end dai travisatori
Peter Cordes,

Entrambi i commenti sopra sembrano ignorare i problemi algoritmici generali e la complessità, a favore della promozione dell'hardware specializzato con istruzioni speciali della macchina. Trovo il primo particolarmente meschino in quanto respinge allegramente le importanti intuizioni generali di questa risposta in cieco favore di istruzioni specializzate sulla macchina.
user2297550,

36

La risposta di Bjarne Stroustrup a questa domanda:

Sembra una domanda da intervista. È vero? Come lo sapresti? È una cattiva idea rispondere alle domande sull'efficienza senza prima fare alcune misurazioni, quindi è importante sapere come misurare.

Quindi, ho provato con un vettore di un milione di numeri interi e ho ottenuto:

Already sorted    32995 milliseconds
Shuffled          125944 milliseconds

Already sorted    18610 milliseconds
Shuffled          133304 milliseconds

Already sorted    17942 milliseconds
Shuffled          107858 milliseconds

L'ho eseguito alcune volte per essere sicuro. Sì, il fenomeno è reale. Il mio codice chiave era:

void run(vector<int>& v, const string& label)
{
    auto t0 = system_clock::now();
    sort(v.begin(), v.end());
    auto t1 = system_clock::now();
    cout << label 
         << duration_cast<microseconds>(t1  t0).count() 
         << " milliseconds\n";
}

void tst()
{
    vector<int> v(1'000'000);
    iota(v.begin(), v.end(), 0);
    run(v, "already sorted ");
    std::shuffle(v.begin(), v.end(), std::mt19937{ std::random_device{}() });
    run(v, "shuffled    ");
}

Almeno il fenomeno è reale con questo compilatore, libreria standard e impostazioni di ottimizzazione. Diverse implementazioni possono dare risposte diverse. In effetti, qualcuno ha fatto uno studio più sistematico (una rapida ricerca sul web lo troverà) e la maggior parte delle implementazioni mostra questo effetto.

Uno dei motivi è la previsione dei rami: l'operazione chiave nell'algoritmo di ordinamento è “if(v[i] < pivot]) …”o equivalente. Per una sequenza ordinata quel test è sempre vero mentre, per una sequenza casuale, il ramo scelto varia in modo casuale.

Un altro motivo è che quando il vettore è già ordinato, non abbiamo mai bisogno di spostare gli elementi nella loro posizione corretta. L'effetto di questi piccoli dettagli è il fattore di cinque o sei che abbiamo visto.

Quicksort (e l'ordinamento in generale) è uno studio complesso che ha attratto alcune delle più grandi menti dell'informatica. Una buona funzione di ordinamento è il risultato sia della scelta di un buon algoritmo, sia della cura dell'hardware nella sua implementazione.

Se vuoi scrivere un codice efficiente, devi sapere qualcosa sull'architettura della macchina.


28

Questa domanda è radicata nei modelli di previsione delle filiali sulle CPU. Consiglierei di leggere questo documento:

Aumentare la frequenza di recupero delle istruzioni tramite la previsione di più rami e una cache dell'indirizzo di ramo

Quando hai ordinato gli elementi, IR non può essere disturbato per recuperare tutte le istruzioni della CPU, ancora e ancora, le recupera dalla cache.


Le istruzioni rimangono attive nella cache delle istruzioni L1 della CPU indipendentemente da errori di previsione. Il problema è portarli nella pipeline nell'ordine giusto, prima che le istruzioni immediatamente precedenti siano state decodificate e abbiano terminato l'esecuzione.
Peter Cordes,

15

Un modo per evitare errori di previsione dei rami è quello di creare una tabella di ricerca e indicizzarla utilizzando i dati. Stefan de Bruijn ne ha discusso nella sua risposta.

Ma in questo caso, sappiamo che i valori sono nell'intervallo [0, 255] e ci preoccupiamo solo di valori> = 128. Ciò significa che possiamo facilmente estrarre un singolo bit che ci dirà se vogliamo un valore o meno: spostando i dati a destra a 7 bit, ci rimane uno 0 bit o 1 bit e vogliamo aggiungere il valore solo quando abbiamo 1 bit. Chiamiamo questo bit il "bit decisionale".

Usando il valore 0/1 del bit di decisione come indice in un array, possiamo creare un codice che sarà altrettanto veloce sia che i dati vengano ordinati o meno. Il nostro codice aggiungerà sempre un valore, ma quando il bit di decisione è 0, aggiungeremo il valore da qualche parte che non ci interessa. Ecco il codice:

// Test

clock_t start = clock();
long long a[] = {0, 0};
long long sum;

for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        int j = (data[c] >> 7);
        a[j] += data[c];
    }
}

double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;
sum = a[1];

Questo codice spreca metà delle aggiunte ma non ha mai un errore di previsione del ramo. È incredibilmente più veloce su dati casuali rispetto alla versione con un'istruzione if effettiva.

Ma nei miei test, una tabella di ricerca esplicita è stata leggermente più veloce di questa, probabilmente perché l'indicizzazione in una tabella di ricerca era leggermente più veloce dello spostamento dei bit. Questo mostra come il mio codice viene impostato e utilizza la tabella di ricerca (in modo inimmaginabile chiamato lut per "Tabella di ricerca" nel codice). Ecco il codice C ++:

// Dichiara e quindi compila la tabella di ricerca

int lut[256];
for (unsigned c = 0; c < 256; ++c)
    lut[c] = (c >= 128) ? c : 0;

// Use the lookup table after it is built
for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        sum += lut[data[c]];
    }
}

In questo caso, la tabella di ricerca era di soli 256 byte, quindi si adattava perfettamente in una cache e tutto era veloce. Questa tecnica non funzionerebbe bene se i dati fossero valori a 24 bit e ne volessimo solo la metà ... la tabella di ricerca sarebbe troppo grande per essere pratica. D'altra parte, possiamo combinare le due tecniche mostrate sopra: prima sposta i bit, quindi indicizza una tabella di ricerca. Per un valore a 24 bit che vogliamo solo il valore della metà superiore, potremmo potenzialmente spostare i dati a destra di 12 bit e rimanere con un valore a 12 bit per un indice di tabella. Un indice di tabella a 12 bit implica una tabella di 4096 valori, che potrebbe essere pratico.

La tecnica di indicizzazione in un array, anziché utilizzare un'istruzione if, può essere utilizzata per decidere quale puntatore utilizzare. Ho visto una libreria che implementava alberi binari e invece di avere due puntatori con nome (pLeft e pRight o altro) aveva un array di puntatori di lunghezza 2 e utilizzava la tecnica del "bit di decisione" per decidere quale seguire. Ad esempio, anziché:

if (x < node->value)
    node = node->pLeft;
else
    node = node->pRight;
this library would do something like:

i = (x < node->value);
node = node->link[i];

è una buona soluzione, forse funzionerà


Con quale compilatore / hardware C ++ hai provato questo e con quali opzioni di compilatore? Sono sorpreso che la versione originale non sia stata auto-vettorializzata in un bel codice SIMD senza rami. Hai abilitato l'ottimizzazione completa?
Peter Cordes,

Una tabella di ricerca 4096 voci sembra folle. Se si sposta fuori qualsiasi bit, è necessario non può al solo utilizzare il risultato LUT, se si desidera aggiungere il numero originale. Tutti questi sembrano trucchi sciocchi per aggirare il compilatore non facilmente usando tecniche senza rami. Più semplice sarebbe mask = tmp < 128 : 0 : -1UL;/total += tmp & mask;
Peter Cordes il
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.