it-swarm.it

Perché è più veloce elaborare una matrice ordinata rispetto a una matrice non ordinata?

Ecco un pezzo di codice C++ che sembra molto particolare. Per qualche strana ragione, l'ordinamento miracolosamente 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, pensavo che questo potesse essere solo un linguaggio o un'anomalia del compilatore. Così l'ho provato in 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 un po '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 era appena stato generato.

  • Cosa sta succedendo?
  • Perché è più veloce elaborare una matrice ordinata rispetto a una matrice non ordinata?
  • Il codice riassume alcuni termini indipendenti e l'ordine non dovrebbe avere importanza.
22968
GManNickG

Sei vittima di previsione ramo fallito.


Cos'è la previsione delle filiali?

Considerare un nodo ferroviario:

 Image showing a railroad junction Immagine di Mecanismo, via Wikimedia Commons. Utilizzato sotto CC-By-SA 3.0 licenza.

Ora per il gusto di argomentare, supponiamo che questo sia tornato nel 1800 - prima di una lunga distanza o di una comunicazione radio.

Sei l'operatore di un incrocio e senti arrivare un treno. Non hai idea di che cosa dovrebbe andare. Si ferma il treno per chiedere all'autista la direzione che vogliono. E quindi si imposta 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à, eseguirà il backup e ti urlerà di lanciare l'interruttore. Quindi può riavviare l'altro percorso.

Se indovini giusto ogni volta , il treno non dovrà mai fermarsi.
Se si indovina troppo spesso , il treno impiegherà molto tempo per fermarsi, fare retromarcia e riavviare.


Considera un'istruzione if: A livello di processore, è un'istruzione branch:

Screenshot of compiled code containing an if statement

Sei un processore e vedi un ramo. Non hai idea di dove 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 lunghe condutture, quindi impiegano un'eternità per "riscaldarsi" e "rallentare".

Esiste un modo migliore? Indovina in quale direzione andrà il ramo!

  • Se hai indovinato, continui a farlo.
  • Se hai indovinato, devi lavare la tubazione e tornare al ramo. Quindi puoi riavviare l'altro percorso.

Se indovini giusto ogni volta , l'esecuzione non dovrà mai fermarsi.
Se indovini troppo spesso , passi molto tempo a rallentare, a rallentare e a riavviare.


Questa è la previsione delle filiali. 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 un ramo andrà fino all'ultimo momento.

Quindi, come indurrebbe strategicamente a ridurre al minimo il numero di volte che il treno deve tornare indietro e percorrere l'altro percorso? Guardi la storia passata! Se il treno va a sinistra il 99% delle volte, allora indovina a sinistra. Se si alterna, allora si alternano le ipotesi. Se va in un modo ogni 3 volte, indovina lo stesso ...

In altre parole, si tenta di identificare un modello e seguirlo.Questo è più o meno come funzionano i predittori di ramo.

La maggior parte delle applicazioni ha rami ben educati. Pertanto, i predittori di ramo moderni raggiungeranno in genere tassi di successo> 90%. Ma di fronte a rami imprevedibili senza schemi riconoscibili, i predittori di ramo sono praticamente inutili.

Ulteriori letture: Articolo "Predittore di rami" su Wikipedia .


Come accennato dall'alto, il colpevole è questa affermazione se:

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

Si noti che i dati sono equamente distribuiti tra 0 e 255. Quando i dati sono ordinati, all'incirca la prima metà delle iterazioni non entrerà nell'istruzione if. Dopo di ciò, entreranno tutti nell'istruzione if.

Questo è molto amichevole per il predittore del ramo poiché il ramo consecutivamente va nella stessa direzione molte volte. Anche un semplice contatore di saturazione predice correttamente il ramo tranne le poche iterazioni dopo che esso 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 ramo è reso inutile perché non può prevedere dati casuali. Quindi ci sarà probabilmente una misprediction di circa il 50%. (non meglio di indovinare casualmente)

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];

Ciò elimina il ramo e lo sostituisce con alcune operazioni bit a bit.

(Si noti 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 @ 3,5 GHz

C++ - Visual Studio 2010 - x64 Release

//  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 il ramo: C'è 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'hack è in realtà un po 'più lento rispetto al branch quando i dati sono ordinati.

Una regola generale è quella di evitare la ramificazione dipendente dai dati nei loop critici. (come in questo esempio)


Aggiornamento:

  • GCC 4.6.1 con -O3 o -ftree-vectorize su x64 è in grado di generare uno spostamento condizionale. Quindi non c'è differenza tra i dati ordinati e quelli non ordinati: entrambi sono veloci.

  • VC++ 2010 non è in grado di generare spostamenti condizionali per questo ramo anche in /Ox.

  • Intel Compiler 11 fa qualcosa di miracoloso. Esso scambia i due anelli , sollevando in tal modo il ramo imprevedibile verso l'anello esterno. Quindi, non solo è immune alle previsioni errate, ma è anche il doppio di qualsiasi altro VC++ e GCC possano generare! In altre parole, ICC ha approfittato del test-loop per sconfiggere il benchmark ...

  • Se si fornisce al compilatore Intel il codice senza diramazione, lo si giustamente destrutturato ... ed è altrettanto veloce come con il ramo (con lo scambio di loop).

Questo dimostra che anche i compilatori moderni maturi possono variare notevolmente nella loro capacità di ottimizzare il codice ...

30104
Mysticial

Predizione del ramo.

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

3879
Daniel Fischer

La ragione per cui le prestazioni migliorano drasticamente quando i dati sono ordinati è che la penalità di predizione del ramo viene rimossa, come spiegato splendidamente in La risposta di Mysticial .

Ora, se guardiamo il codice

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

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

In C, quindi C++, l'istruzione, che compilerebbe direttamente (senza alcuna ottimizzazione) nell'istruzione di movimento condizionale in x86, è l'operatore ternario ... ? ... : .... Quindi riscriviamo l'affermazione precedente in una equivalente:

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

Mantenendo la leggibilità, possiamo controllare il fattore di accelerazione.

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

x86

//  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 è robusto in più test. Otteniamo una grande accelerazione quando il risultato del ramo è imprevedibile, ma soffriamo un po 'quando è prevedibile. Infatti, quando si utilizza una mossa condizionale, le prestazioni sono le stesse indipendentemente dal modello di dati.

Ora esaminiamo più da vicino l'analisi dell'assembly x86 che generano. Per semplicità, usiamo due funzioni max1 e max2.

max1 utilizza il ramo condizionale if... else ...:

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

max2 utilizza l'operatore ternario ... ? ... : ...:

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

Su una macchina x86-64, GCC -S genera l'assembly 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

max2 utilizza molto meno codice a causa dell'uso dell'istruzione cmovge. Ma il vero vantaggio è che max2 non coinvolge branch jumps, jmp, che avrebbe una significativa penalizzazione delle prestazioni se il risultato previsto non fosse corretto.

Quindi perché una mossa condizionale ha prestazioni migliori?

In un tipico processore x86, l'esecuzione di un'istruzione è divisa in più fasi. Approssimativamente, abbiamo diversi hardware per gestire le diverse fasi. Quindi non dobbiamo aspettare che un'istruzione finisca per avviarne una nuova. Questo è chiamatopipelining.

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

In un caso di movimento condizionale, l'istruzione di spostamento condizionale di esecuzione è divisa in più fasi, ma le fasi precedenti come Fetch e Decode non dipendono dal risultato dell'istruzione precedente; solo le ultime fasi necessitano del risultato. Quindi, aspettiamo una frazione del tempo di esecuzione di una istruzione. Questo è il motivo per cui la versione con spostamento condizionale è più lenta del ramo quando la previsione è semplice.

Il libro Computer Systems: A Programmer's Perspective, seconda edizione lo spiega in dettaglio. È possibile consultare la Sezione 3.6.6 per Istruzioni di spostamento condizionale, l'intero Capitolo 4 per Processor Architecture e la Sezione 5.11.2 per un trattamento speciale per Predizione di ramo e penalità di malformazione.

A volte, alcuni compilatori moderni possono ottimizzare il nostro codice in Assembly 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 mossa condizionale quando imprevedibile può aiutarci a scrivere codice con prestazioni migliori quando lo scenario diventa così complesso che il compilatore non può ottimizzarlo automaticamente.

3125
WiSaGaN

Se sei curioso di ulteriori ottimizzazioni che possono essere fatte per questo codice, considera questo:

A partire dal ciclo originale:

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

Con lo scambio di loop, possiamo tranquillamente cambiare questo loop per:

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 condizionale if è costante durante l'esecuzione del ciclo i, quindi puoi issare if out:

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

Quindi, si vede che il ciclo interno può essere collassato in una singola espressione, assumendo che il modello a virgola mobile lo consenta (ad esempio// fp: veloce);

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

Quello è 100.000 volte più veloce di prima

2143
vulcan raven

Senza dubbio alcuni di noi sarebbero interessati ai modi di identificare il codice che è problematico per il predittore di ramo della CPU. Lo strumento Valgrind cachegrind ha un simulatore di branch-predictor, abilitato usando il flag --branch-sim=yes. Eseguendolo sopra gli esempi in questa domanda, con il numero di cicli esterni ridotti a 10000 e compilato con g++, si ottengono questi risultati:

Ordinato:

==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%   )

Non ordinato:

==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 line-by-line prodotto da cg_annotate vediamo il ciclo in questione:

Ordinato:

          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];
           .      .  .   .          }
           .      .  .   .      }

Non ordinato:

          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];
           .           .  .   .          }
           .           .  .   .      }

Questo ti permette di identificare facilmente la linea problematica - nella versione non smistata la linea if (data[c] >= 128) sta causando 164.050.007 rami condizionali erroneamente detti (Bcm) nel modello predittore di branch di cachegrind, mentre sta solo causando 10.006 nella versione ordinata.


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

perf stat ./sumtest_sorted

Ordinato:

 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

Non ordinato:

 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 fare annotazione del codice sorgente con il disassemblaggio.

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.

1784
caf

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

Un metodo comune per eliminare la previsione di branch che ho trovato particolarmente utile nei linguaggi gestiti è la ricerca di tabelle invece di utilizzare un ramo (anche se in questo caso non l'ho testato).

Questo approccio funziona in generale se:

  1. è una tabella piccola ed è probabile che venga memorizzata nella cache del processore e
  2. si sta eseguendo le cose in un ciclo piuttosto stretto e/o il processore può precaricare i dati.

Background e perché

Dal punto di vista del processore, la tua memoria è lenta. Per compensare la differenza di velocità, un paio di cache sono integrate nel processore (cache L1/L2). Quindi immagina che stai facendo i tuoi bei calcoli e capisci che hai bisogno di un pezzo di memoria. Il processore avrà il suo funzionamento 'carico' e caricherà il pezzo di memoria nella cache - e quindi utilizzerà la cache per fare il resto dei calcoli. Poiché la memoria è relativamente lenta, questo "caricamento" rallenterà il tuo programma.

Come la previsione del ramo, questo è stato ottimizzato nei processori Pentium: il processore prevede che è necessario 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 delle filiali a volte è terribilmente sbagliata - nel peggiore dei casi è necessario tornare indietro e attendere effettivamente un carico di memoria, che richiederà un tempo indefinito ( in altre parole: la previsione del ramo fallita è sbagliata, un carico di memoria dopo un errore di 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 è che cos'è small ? Mentre generalmente più piccolo è meglio, una regola empirica è di attenersi a tabelle di ricerca con dimensioni <= 4096 byte. Come limite superiore: se la tua tabella di ricerca è più grande di 64 KB, probabilmente vale la pena riconsiderare.

Costruire un tavolo

Quindi abbiamo capito che possiamo creare un tavolino. La prossima cosa da fare è ottenere una funzione di ricerca sul posto. Le funzioni di ricerca sono in genere piccole funzioni che utilizzano un paio di operazioni di base integer (e, o, xor, shift, aggiungi, rimuovi e forse moltiplica). Vuoi che il tuo contributo venga tradotto dalla funzione di ricerca su una "chiave unica" nella tua tabella, che 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 ci liberiamo di esso. Il modo più semplice per farlo è usare un 'AND': se lo teniamo, noi e lui con 7FFFFFFF; se vogliamo sbarazzarci di esso, noi di AND lo con 0. Notate anche che 128 è una potenza di 2 - quindi possiamo andare avanti e creare una tabella di numeri interi 32768/128 e riempirla con uno zero e un sacco di 7FFFFFFFF di.

Lingue gestite

Potresti chiederti perché questo funziona bene nelle lingue gestite. Dopo tutto, le lingue gestite controllano i confini degli array con un ramo per assicurarti di non rovinare ...

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 al compilatore che la condizione al contorno non verrà mai colpita. Almeno il compilatore Microsoft JIT (ma mi aspetto che Java faccia cose simili) lo noterà e rimuoverà del tutto il controllo. WOW, questo significa nessun ramo. Allo stesso modo, si occuperà di altri casi ovvi.

Se si incontrano problemi con le ricerche nelle lingue gestite, la chiave è aggiungere un & 0x[something]FFF alla funzione di ricerca per rendere prevedibile il controllo dei limiti e osservarlo 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();
1247
atlaste

Dato che i dati vengono distribuiti tra 0 e 255 quando l'array è ordinato, intorno alla prima metà delle iterazioni non verrà inserita l'if- istruzione (l'istruzione if è condivisa di seguito).

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

La domanda è: cosa rende la dichiarazione precedente non eseguita in alcuni casi come nel caso dei dati ordinati? Arriva il "predittore del ramo". Un predittore di branche è un circuito digitale che cerca di indovinare in quale direzione un ramo (ad esempio una struttura if-then-else) andrà prima che questo sia noto. Lo scopo del predittore di branca è di migliorare il flusso nella pipeline di istruzioni. I predittori di ramo svolgono un ruolo fondamentale nel raggiungimento di alte prestazioni efficaci!

Facciamo un po 'di benchmark per comprenderlo meglio

Le prestazioni di un'istruzione if- dipendono dal fatto che la sua condizione abbia uno schema prevedibile. Se la condizione è sempre vera o sempre falsa, la logica di predizione del ramo nel processore preleverà il modello. D'altra parte, se il modello è imprevedibile, la dichiarazione if- sarà molto più costosa.

Misuriamo le prestazioni di questo ciclo con condizioni diverse:

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

Ecco i tempi del loop con diversi pattern 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 " bad " true-false può rendere un if- statement fino a sei volte più lento di un pattern " good "! Naturalmente, quale modello è buono e quale è cattivo dipende dalle esatte istruzioni generate dal compilatore e dal processore specifico.

Quindi non ci sono dubbi sull'impatto della previsione dei rami sulle prestazioni!

1118
Saqlain

Un modo per evitare errori di previsione delle diramazioni è 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 interessano solo i valori> = 128. Ciò significa che possiamo facilmente estrarre un singolo bit che ci dirà se vogliamo o meno un valore: spostando i dati a destra 7 bit, ci rimane un 0 bit o 1 bit, e vogliamo solo aggiungere il valore quando abbiamo 1 bit. Chiamiamo questo bit il "bit di decisione".

Usando il valore 0/1 del bit di decisione come indice in un array, possiamo creare un codice che sarà altrettanto veloce se i dati sono ordinati o non ordinati. Il nostro codice aggiungerà sempre un valore, ma quando il bit di decisione è 0, aggiungeremo il valore da qualche parte a cui non 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à degli add, ma non ha mai avuto un errore di previsione del ramo. È tremendamente più veloce su dati casuali rispetto alla versione con una dichiarazione if effettiva.

Ma nei miei test, una tabella di ricerca esplicita era 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 imprevisto lut per "LookUp Table" 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 solo 256 byte, quindi si adattava bene in una cache e tutto era veloce. Questa tecnica non funzionerebbe bene se i dati fossero valori a 24 bit e volevamo solo metà di essi ... la tabella di ricerca sarebbe stata troppo grande per essere pratica. D'altra parte, possiamo combinare le due tecniche mostrate sopra: prima spostate i bit, quindi indicizzate 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 lasciare 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 una matrice, invece di 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 nominati (pLeft e pRight o qualsiasi altra cosa) aveva una serie di puntatori lunghezza 2 e usava la tecnica del "decision bit" per decidere quale seguire. Ad esempio, invece di:

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

questa libreria farebbe qualcosa come:

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

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

1039
steveha

Nel caso ordinato, puoi fare meglio che fare affidamento sulla previsione dei rami o su un trucco di confronto senza ramo: rimuovi completamente il ramo.

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

Qualcosa come (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 fornisce una soluzione approximate per entrambi ordinati o non ordinati è: sum= 3137536; (presupponendo una distribuzione veramente uniforme, 16384 campioni con valore previsto 191,5) :-)

942
Yves Daoust

Il comportamento sopra riportato sta accadendo a causa della previsione Branch.

Per capire la previsione dei rami bisogna prima capire Pipeline di istruzioni :

Qualsiasi 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 il throughput nei processori moderni. Per capirlo meglio, per favore vedi questo esempio su Wikipedia .

In generale, i processori moderni hanno pipeline piuttosto lunghe, ma per comodità consideriamo solo questi 4 passaggi.

  1. IF: recupera l'istruzione dalla memoria
  2. ID: decodifica l'istruzione
  3. EX - Esegui l'istruzione
  4. WB - Scrivi di nuovo nel registro della CPU

Conduttura a 4 stadi in generale per 2 istruzioni. 4-stage pipeline in general

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 verifica 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 andare all'istruzione B o l'istruzione C dipende dal risultato dell'istruzione A. Quindi la pipeline sarà simile a questo.

quando if condizione restituisce true: enter image description here

Se la condizione restituisce false: enter image description here

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

Allora, qual è la previsione del ramo?

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

In caso di ipotesi corretta, la pipeline è simile a questa: enter image description here

Se successivamente viene rilevato che l'ipotesi è sbagliata, le istruzioni parzialmente eseguite vengono scartate e la pipeline si avvia con il ramo corretto, con un ritardo. Il tempo che viene sprecato in caso di misprediction di un ramo è uguale al numero di stadi nella pipeline dalla fase di recupero alla fase di esecuzione. I microprocessori moderni tendono ad avere condutture piuttosto lunghe in modo che il ritardo di errore sia compreso tra 10 e 20 cicli di clock. Più lunga è la pipeline, maggiore è la necessità di un buon predittore di branch .

Nel codice dell'OP, la prima volta quando il condizionale, il predittore del ramo non ha alcuna informazione per basare la previsione, quindi la prima volta sceglierà in modo casuale l'istruzione successiva. Più avanti nel ciclo for, può basare la previsione sulla storia. Per un array ordinato in ordine crescente, ci sono tre possibilità:

  1. Tutti gli elementi sono meno di 128
  2. Tutti gli elementi sono maggiori di 128
  3. Alcuni nuovi elementi di partenza sono inferiori a 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 ramo vero poiché storicamente tutte le sue previsioni sono corrette. Nel secondo caso, inizialmente prevarrà, ma dopo alcune iterazioni, predicherà correttamente. Nel 3 ° caso, inizialmente prevarrà correttamente fino a quando gli elementi saranno inferiori a 128. Dopo di ciò, fallirà per un po 'di tempo e sarà corretto quando vedrà un errore di previsione del ramo nella storia.

In tutti questi casi l'errore sarà troppo basso e, di conseguenza, solo poche volte sarà necessario scartare le istruzioni parzialmente eseguite e ricominciare con il ramo corretto, con un conseguente minor numero di 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 la maggior parte del tempo e produrre più cicli della CPU rispetto alla matrice ordinata.

765
Harsh Sharma

Una risposta ufficiale sarebbe da

  1. Intel - Evitare il costo della mispredicione dei rami
  2. Intel - Riorganizzazione di rami e loop per prevenire i malintenzionati
  3. Articoli scientifici - architettura informatica di previsione delle filiali
  4. Libri: J.L. Hennessy, D.A. Patterson: architettura del computer: un approccio quantitativo
  5. Articoli in pubblicazioni scientifiche: T.Y. Yeh, Y.N. Patt ha fatto molte di queste sulle previsioni del ramo.

Puoi anche vedere da questo adorabile diagramma perché il predittore di branchi si confonde.

 2-bit state diagram

Ogni elemento nel codice originale è un valore casuale

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

quindi il predittore cambierà i lati mentre il std::Rand() soffia.

D'altra parte, una volta che è stato ordinato, il predittore si muoverà prima in uno stato fortemente non preso e quando i valori cambiano al valore alto il predittore sarà in tre passaggi attraverso il cambiamento da fortemente non preso a fortemente preso.


669
Surt

Nella stessa linea (penso che questo non sia stato evidenziato da nessuna risposta) è bene menzionare che a volte (specialmente nel software in cui le prestazioni sono importanti, come nel kernel di Linux) si possono trovare alcune affermazioni come le seguenti:

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

o allo stesso modo:

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

Entrambe le funzioni likely() e unlikely() sono in effetti delle macro che vengono definite utilizzando qualcosa come G__'s___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 builtin 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 builtin del GCC disponibili.

Normalmente questo tipo di ottimizzazioni si trova principalmente in applicazioni hard-real-time o in sistemi embedded in cui il tempo di esecuzione è importante ed è fondamentale. Ad esempio, se stai verificando qualche condizione di errore che accade solo 1/10000000 volte, allora perché non informarne il compilatore? In questo modo, per impostazione predefinita, la previsione del ramo presuppone che la condizione sia falsa.

634
rkachach

Le operazioni booleane usate frequentemente in C++ producono molti rami nel programma compilato. Se questi rami sono all'interno di cicli e sono difficili da prevedere, possono rallentare notevolmente l'esecuzione. Le variabili booleane vengono memorizzate come numeri interi a 8 bit con il valore 0 per false e 1 per true.

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

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

Questo è in genere 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. I rami possono richiedere molto tempo in caso di previsioni errate. Le operazioni booleane possono essere rese molto più efficienti se è noto con certezza che gli operandi non hanno altri valori oltre 0 e 1. Il motivo per cui il compilatore non fa una 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 a e b sono stati inizializzati su valori validi o se provengono da operatori che producono output booleano. Il codice ottimizzato si presenta così:

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

char è usato al posto di bool per poter utilizzare gli operatori bitwise (& e |) al posto degli operatori booleani (&& e ||). Gli operatori bit a bit sono istruzioni singole che richiedono solo un ciclo di clock. L'operatore OR (|) funziona anche se a e b hanno valori diversi da 0 o 1. L'operatore AND (&) e l'operatore OR ESCLUSIVO (^) possono fornire risultati incoerenti se gli operandi hanno valori diversi da 0 e 1.

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

bool a, b;
b = !a;

può essere ottimizzato per:

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

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

L'utilizzo di operatori bit a bit è più vantaggioso se gli operandi sono variabili rispetto a 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 errate previsioni sui rami).

603
Maciej

Certamente!...

Predizione branch rende la logica più lenta, a causa dello switching che avviene nel tuo codice! È come se steste andando su una strada dritta o su una strada con molte svolte, di sicuro la scala sarà fatta più veloce! ...

Se l'array è ordinato, la tua condizione è falsa al primo passaggio: data[c] >= 128, quindi diventa un valore vero per tutto il percorso fino alla fine della strada. Ecco come si arriva alla fine della logica più velocemente. D'altra parte, usando un array non ordinato, è necessario un sacco di svolte e di processi che rendono il tuo codice più lento di sicuro ...

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

 Branch Prediction

Quindi programmaticamente, forecast del ramo fa sì che il processo sia più lento ...

Inoltre, è bene sapere che abbiamo due tipi di previsioni sulle branch che influenzeranno il tuo codice in modo diverso:

1. Statico

2. Dinamico

 Branch Prediction

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

Per scrivere efficacemente il tuo codice per sfruttare queste regole, quando scrivi if-else o switch statement, controlla prima i casi più comuni e lavora progressivamente verso il meno comune. I loop non richiedono necessariamente un ordinamento di codice speciale per la previsione del ramo statico, in quanto viene normalmente utilizzata solo la condizione dell'iter iter di loop.

280
Alireza

A questa domanda è già stata data una risposta eccellente molte volte. Vorrei tuttavia attirare l'attenzione del gruppo su un'altra interessante analisi.

Recentemente questo esempio (leggermente modificato) è stato usato anche come un 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 trascorre la maggior parte del tempo in entrambi i casi ordinati e non ordinati. Infine, il pezzo mostra anche come usare una caratteristica poco conosciuta dell'HAL (Hardware Abstraction Layer) per determinare quanta parte della mis- tipicazione delle branche sta accadendo nel caso non ordinato.

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

262
ForeverLearning

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

Non sto cercando di aggiungere qualcosa, ma di spiegare il concetto in un altro modo. C'è un'introduzione concisa sul wiki che contiene testo e diagramma. Mi piace la spiegazione in basso che utilizza un diagramma per elaborare intuitivamente il Predictor del ramo.

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

La ramificazione bidirezionale viene solitamente implementata con un'istruzione di salto condizionale. Un salto condizionato può essere "non preso" e continuare l'esecuzione con il primo ramo di codice che segue immediatamente dopo il salto condizionato, oppure può essere "preso" e passare a un altro posto nella memoria di programma dove il secondo ramo di codice è immagazzinato. Non è noto con certezza se un salto condizionato sarà preso o non preso fino a quando la condizione non sarà stata calcolata e il salto condizionato ha superato la fase di esecuzione nella pipeline di istruzioni (vedi figura 1).

 figure 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 Predictor del ramo.

Senza la previsione delle diramazioni, il processore dovrebbe attendere che l'istruzione di salto condizionale abbia passato 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 andare nella pipeline fino a quando non viene eseguita l'istruzione di salto condizionale.

 without branch predictor

Ci vorranno 9 cicli di clock per 3 istruzioni da completare.

  1. Usa Branch Predictor e non fare un salto condizionale. Supponiamo che il predizione sia non prendendo il salto condizionato.

 enter image description here

Ci vorranno 7 cicli di clock per 3 istruzioni da completare.

  1. Usa Branch Predictor e fai un salto condizionale. Supponiamo che il predizione sia non prendendo il salto condizionato.

 enter image description here

Ci vorranno 9 cicli di clock per 3 istruzioni da completare.

Il tempo che viene sprecato in caso di misprediction di un ramo è uguale al numero di stadi nella pipeline dalla fase di recupero alla fase di esecuzione. I microprocessori moderni tendono ad avere condutture piuttosto lunghe in modo che il ritardo di errore sia compreso tra 10 e 20 cicli di clock. Di conseguenza, rendendo la pipeline più lunga aumenta la necessità di un predittore di ramo più avanzato.

Come puoi vedere, sembra che non abbiamo una ragione per non usare Branch Predictor.

È una demo abbastanza semplice che chiarisce la parte fondamentale di Branch Predictor. Se quelle gif sono fastidiose, non esitare a rimuoverle dalla risposta e i visitatori possono anche ottenere la demo da git

176
Gearon

Guadagno pronostico!

È importante capire che il malinteso del ramo non rallenta i programmi. Il costo di una previsione mancata è proprio come se la previsione delle filiali non esistesse e si aspettava che la valutazione dell'espressione decidesse quale codice eseguire (ulteriori spiegazioni nel paragrafo successivo).

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 condizionali branch .

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 delle istruzioni di esecuzione nell'ordine (cioè se l'espressione è falsa, il programma salta il codice del blocco if) in base ad alcune condizioni, che è la valutazione dell'espressione nel nostro caso.

Detto questo, il compilatore cerca di prevedere l'esito prima che venga effettivamente valutato. Recupererà le istruzioni dal blocco if, e se l'espressione risulta vera, allora meraviglioso! Abbiamo guadagnato il tempo necessario per valutarlo e fatto progressi nel codice; in caso contrario, stiamo eseguendo il codice sbagliato, 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 hai aspettato, oppure potresti scegliere il percorso1 e se sei stato fortunato (l'itinerario 1 è il percorso corretto), poi fantastico non hai dovuto aspettare che il tuo partner controllasse la mappa (hai salvato il tempo che gli sarebbe occorso per controllare la mappa), altrimenti tornerai indietro.

Mentre le linee di scarico sono super veloci, oggi vale la pena scommettere su questa scommessa. La previsione di dati ordinati o di dati che cambiano lentamente è sempre più facile e migliore della previsione di modifiche veloci.

 O      Route 1  /-------------------------------
/|\             /
 |  ---------##/
/ \            \
                \
        Route 2  \--------------------------------
168
Tony Tannous

Si tratta della previsione delle filiali. Che cos'è?

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

  • D'altra parte, le previsioni di branch complesse - o basate su neurale o varianti di prediction branch a due livelli - forniscono una migliore accuratezza di previsione, ma consumano più potenza e la complessità aumenta esponenzialmente.

  • Oltre a ciò, nelle tecniche di previsione complesse il tempo necessario per prevedere i rami è di per sé molto elevato, da 2 a 5 cicli, che è paragonabile al tempo di esecuzione dei rami effettivi.

  • La previsione di branch è essenzialmente un problema di ottimizzazione (minimizzazione) in cui l'enfasi è posta su un tasso di mancato tasso minimo, un basso consumo energetico e una bassa complessità con risorse minime.

Ci sono davvero tre diversi tipi di rami:

Inoltra rami condizionali - in base a una condizione di runtime, il PC (contatore di programma) viene modificato in modo che punti a un indirizzo in avanti nel flusso di istruzioni.

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

Rami incondizionati - questo include salti, chiamate di procedure e ritorni che non hanno una condizione specifica. Ad esempio, un'istruzione di salto incondizionata potrebbe essere codificata in 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 condizionato che potrebbe essere codificato come "jmpne" reindirizza il flusso di istruzioni solo se il risultato di un confronto di due valori in una precedente istruzione "compare" 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" (al di fuori del segmento). Ogni tipo ha effetti diversi sugli algoritmi di previsione dei rami.)

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

Riferimenti:

113
Farhad

Oltre al fatto che la previsione del ramo può rallentare, un array ordinato ha un altro vantaggio:

Puoi avere una condizione di arresto invece di limitarti a controllare il valore, in questo modo puoi solo eseguire il loop dei dati rilevanti e ignorare 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];               
 }
107
Yochai Timmer

Su ARM, non è necessario alcun ramo, poiché ogni istruzione ha un campo di condizioni a 4 bit, che viene testato a costo zero. Questo elimina la necessità di rami brevi, e non ci sarebbe alcun colpo di predizione di ramo. Pertanto, la versione ordinata sarebbe più lenta della versione non ordinata su ARM, a causa del sovraccarico extra di ordinamento. Il ciclo interno sarebbe simile al seguente:

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
103
Luke Hutchison

Le matrici ordinate vengono elaborate più rapidamente di una matrice non ordinata, a causa di fenomeni denominati previsione dei rami.

Il predittore di ramo è un circuito digitale (nell'architettura dei computer) che tenta di prevedere in che direzione andrà un ramo, migliorando il flusso nella pipeline di istruzioni. Il circuito/computer predice il prossimo passo e lo esegue.

Effettuare una previsione errata porta a tornare al passaggio precedente e all'esecuzione con un'altra previsione. Supponendo che la previsione sia corretta, il codice continuerà con il passaggio successivo. La previsione errata risulta nel ripetere lo stesso passo, fino a quando si verifica la previsione corretta.

La risposta alla tua domanda è molto semplice.

In una matrice non ordinata, il computer fa più previsioni, portando a una maggiore possibilità di errori. Mentre, in ordine, il computer fa meno pronostici riducendo la possibilità di errori. Fare più previsioni richiede più tempo.

Matrice ordinata: Straight Road

____________________________________________________________________________________
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 
TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT

Matrice non ordinata: strada curva

______   ________
|     |__|

Predizione del ramo: indovinare/prevedere quale strada è diritta e seguirla senza controllo

___________________________________________ Straight road
 |_________________________________________|Longer road

Sebbene entrambe le strade raggiungano la stessa destinazione, la strada diritta è più breve e l'altra più lunga. Se poi si sceglie l'altro per errore, non si può tornare indietro e quindi si sprecherà un po 'di tempo extra se si sceglie la strada più lunga. Questo è simile a ciò che accade sul computer, e spero che questo ti abbia aiutato a capire meglio.


Voglio anche citare @Simon_Weaver dai commenti:

Non fa meno previsioni - fa meno previsioni sbagliate. Deve ancora prevedere per ogni volta attraverso il loop ..

92
Omkaar.K

L'assunto da altre risposte che è necessario ordinare i dati non è corretto.

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

L'ordinamento delle sole sezioni k completa la pre-elaborazione in tempo lineare piuttosto che n.log(n).

#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 alcun problema algoritmico come l'ordinamento, ed è in effetti la previsione delle diramazioni.

14
user2297550

Perché è ordinato!

È facile recuperare e manipolare i dati ordinati che non ordinati.

Proprio come ho scelto gli abiti dai negozi (ordinati) e dal mio guardaroba (incasinato).

0
Arun Joshla