it-swarm.com.de

Warum verarbeitet ein sortiertes Array schneller als ein unsortiertes Array?

Hier ist ein Teil des C++ - Codes, der ein sehr merkwürdiges Verhalten zeigt. Aus irgendeinem Grund beschleunigt das Sortieren der Daten den Code auf wundersame Weise fast um das Sechsfache:

#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;
}
  • Ohne std::sort(data, data + arraySize); läuft der Code in 11,54 Sekunden.
  • Mit den sortierten Daten läuft der Code in 1,93 Sekunden.

Ursprünglich dachte ich, dass dies nur eine Sprach- oder Compileranomalie sein könnte, also habe ich Java ausprobiert:

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);
    }
}

mit einem ähnlichen, aber weniger extremen Ergebnis.


Mein erster Gedanke war, dass das Sortieren die Daten in den Cache bringt, aber dann dachte ich, wie albern das war, weil das Array gerade generiert wurde.

  • Was ist los?
  • Warum verarbeitet ein sortiertes Array schneller als ein unsortiertes Array? Der Code fasst einige unabhängige Begriffe zusammen, daher sollte die Reihenfolge keine Rolle spielen.
23354
GManNickG

Sie sind ein Opfer von Verzweigungsvorhersage gescheitert.


Was ist die Branchenvorhersage?

Betrachten Sie einen Eisenbahnknotenpunkt:

Image showing a railroad junction Image von Mecanismo über Wikimedia Commons. Wird unter der CC-By-SA 3. Lizenz verwendet.

Nehmen wir nun an, dies liegt im 19. Jahrhundert vor der Fern- oder Funkkommunikation.

Sie sind der Betreiber einer Kreuzung und Sie hören einen Zug kommen. Sie haben keine Ahnung, in welche Richtung es gehen soll. Sie halten den Zug an und fragen den Fahrer, in welche Richtung er fahren möchte. Und dann stellen Sie den Schalter entsprechend ein.

Züge sind schwer und haben viel Trägheit. Sie brauchen also eine Ewigkeit, um anzufangen und langsamer zu werden.

Gibt es einen besseren Weg? Sie raten, in welche Richtung der Zug fahren wird!

  • Wenn Sie richtig geraten haben, geht es weiter.
  • Wenn Sie falsch geraten haben, stoppt der Kapitän, macht einen Rückzieher und schreit Sie an, um den Schalter zu betätigen. Dann kann es den anderen Pfad neu starten.

Wenn Sie jedes Mal richtig raten , wird der Zug niemals anhalten müssen.
Wenn Sie zu oft falsch raten , verbringt der Zug viel Zeit damit, anzuhalten, zu sichern und neu zu starten.


Betrachten Sie eine if-Anweisung: Auf Prozessorebene handelt es sich um eine Verzweigungsanweisung:

Screenshot of compiled code containing an if statement

Sie sind ein Verarbeiter und Sie sehen einen Zweig. Sie haben keine Ahnung, in welche Richtung es gehen wird. Wie geht's? Sie stoppen die Ausführung und warten, bis die vorherigen Anweisungen vollständig sind. Dann geht es weiter auf dem richtigen Weg.

Moderne Prozessoren sind kompliziert und haben lange Pipelines. Sie brauchen also eine Ewigkeit, um sich aufzuwärmen und zu verlangsamen.

Gibt es einen besseren Weg? Sie raten, in welche Richtung der Zweig gehen wird!

  • Wenn Sie richtig geraten haben, setzen Sie die Ausführung fort.
  • Wenn Sie falsch geraten haben, müssen Sie die Pipeline spülen und zum Zweig zurückrollen. Dann können Sie den anderen Pfad neu starten.

Wenn Sie jedes Mal richtig raten , wird die Ausführung niemals anhalten müssen.
Wenn Sie zu oft falsch raten , verbringen Sie viel Zeit mit Stehenbleiben, Zurückrollen und Neustarten.


Dies ist eine Verzweigungsvorhersage. Ich gebe zu, es ist nicht die beste Analogie, da der Zug die Richtung nur mit einer Fahne signalisieren könnte. Bei Computern weiß der Prozessor jedoch bis zum letzten Moment nicht, in welche Richtung eine Verzweigung gehen wird.

Wie würden Sie also strategisch raten, um die Häufigkeit zu minimieren, mit der der Zug den anderen Weg zurücklegen und zurücklegen muss? Sie schauen auf die Vergangenheit! Wenn der Zug 99% der Zeit nach links fährt, ist er vermutlich nach links gefahren. Wenn es wechselt, wechseln Sie Ihre Vermutungen. Wenn es alle drei Male in eine Richtung geht, raten Sie dasselbe ...

Mit anderen Worten, Sie versuchen, ein Muster zu identifizieren und ihm zu folgen. Das ist mehr oder weniger so Branch Predictors funktionieren.

Die meisten Anwendungen haben gut erzogene Zweige. Moderne Branch Predictors erreichen also in der Regel eine Trefferquote von> 90%. Bei unvorhersehbaren Verzweigungen ohne erkennbare Muster sind Verzweigungsvorhersagen jedoch praktisch nutzlos.

Weiterführende Literatur: Artikel "Branch Predictor" auf Wikipedia .


Wie oben angedeutet, ist der Schuldige diese if-Anweisung:

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

Beachten Sie, dass die Daten gleichmäßig zwischen 0 und 255 verteilt sind. Wenn die Daten sortiert werden, wird in etwa die erste Hälfte der Iterationen nicht in die if-Anweisung eingegeben. Danach geben sie alle die if-Anweisung ein.

Dies ist für die Verzweigungsvorhersage sehr freundlich, da die Verzweigung viele Male nacheinander dieselbe Richtung einschlägt. Sogar ein einfacher Sättigungszähler sagt die Verzweigung bis auf die wenigen Iterationen nach dem Richtungswechsel korrekt voraus.

Schnelle Visualisierung:

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)

Wenn die Daten jedoch vollständig zufällig sind, wird die Verzweigungsvorhersage unbrauchbar, da sie keine zufälligen Daten vorhersagen kann. Somit wird es wahrscheinlich etwa 50% Fehlvorhersagen geben (nicht besser als zufällige Vermutungen).

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)

Was kann man also tun?

Wenn der Compiler den Zweig nicht in einen bedingten Zug optimieren kann, können Sie einige Hacks ausprobieren, wenn Sie bereit sind, die Lesbarkeit für die Leistung zu opfern.

Ersetzen:

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

mit:

int t = (data[c] - 128) >> 31;
sum += ~t & data[c];

Dadurch wird die Verzweigung entfernt und durch einige bitweise Operationen ersetzt.

(Beachten Sie, dass dieser Hack nicht unbedingt der ursprünglichen if-Anweisung entspricht. In diesem Fall gilt er jedoch für alle Eingabewerte von data[].)

Benchmarks: Core i7 920 bei 3,5 GHz

C++ - Visual Studio 2010 - x64-Version

//  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

Beobachtungen:

  • Mit dem Zweig: Es gibt einen großen Unterschied zwischen den sortierten und unsortierten Daten.
  • Beim Hack: Es gibt keinen Unterschied zwischen sortierten und unsortierten Daten.
  • In C++ ist der Hack tatsächlich etwas langsamer als bei der Verzweigung, wenn die Daten sortiert werden.

Eine allgemeine Faustregel ist, datenabhängige Verzweigungen in kritischen Schleifen (wie in diesem Beispiel) zu vermeiden.


Update:

  • GCC 4.6.1 mit -O3 oder -ftree-vectorize auf x64 kann eine bedingte Bewegung generieren. Es gibt also keinen Unterschied zwischen sortierten und unsortierten Daten - beide sind schnell.

  • VC++ 2010 kann auch unter /Ox keine bedingten Verschiebungen für diesen Zweig generieren.

  • Intel C++ Compiler (ICC) 11 tut etwas Wunderbares. Es vertauscht die beiden Schleifen , wodurch der unvorhersehbare Zweig zur äußeren Schleife angehoben wird. Es ist also nicht nur immun gegen falsche Vorhersagen, sondern auch doppelt so schnell wie alles, was VC++ und GCC erzeugen können! Mit anderen Worten, ICC nutzte die Testschleife, um den Benchmark zu besiegen ...

  • Wenn Sie dem Intel-Compiler den Code ohne Verzweigung geben, vektorisiert er ihn genau richtig ... und ist genauso schnell wie mit der Verzweigung (mit dem Schleifenaustausch).

Dies zeigt, dass selbst ausgereifte, moderne Compiler in ihrer Fähigkeit, Code zu optimieren, stark variieren können ...

30545
Mysticial

Verzweigungsvorhersage

Bei einem sortierten Array ist die Bedingung data[c] >= 128 für einen Streifen von Werten zuerst false und wird dann für alle späteren Werte true. Das ist leicht vorherzusagen. Bei einem unsortierten Array zahlen Sie die Verzweigungskosten.

3943
Daniel Fischer

Der Grund, warum sich die Leistung drastisch verbessert, wenn die Daten sortiert werden, ist, dass die Strafe für die Verzweigungsvorhersage entfernt wird, wie in Antwort von Mysticial .

Nun, wenn wir uns den Code ansehen

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

wir können feststellen, dass die Bedeutung dieses bestimmten if... else... Zweigs darin besteht, etwas hinzuzufügen, wenn eine Bedingung erfüllt ist. Diese Art von Verzweigung kann leicht in eine bedingte Verschiebungsanweisung umgewandelt werden, die in eine bedingte Verschiebungsanweisung kompiliert wird: cmovl, in einer x86 System. Die Verzweigung und damit die mögliche Verzweigungsvorhersage-Strafe wird entfernt.

In C, also C++, ist die Anweisung, die direkt (ohne Optimierung) in die bedingte Verschiebungsanweisung in x86 kompiliert würde, der ternäre Operator ... ? ... : .... Also schreiben wir die obige Aussage in eine äquivalente um:

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

Während die Lesbarkeit erhalten bleibt, können wir den Beschleunigungsfaktor überprüfen.

Auf einem Intel Core i7 - 2600K bei 3,4 GHz und im Visual Studio 2010-Veröffentlichungsmodus lautet der Benchmark (aus Mysticial kopiertes Format):

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

Das Ergebnis ist in mehreren Tests robust. Wir bekommen eine große Beschleunigung, wenn das Ergebnis der Verzweigung nicht vorhersehbar ist, aber wir leiden ein wenig, wenn es vorhersehbar ist. Tatsächlich ist die Leistung bei Verwendung einer bedingten Verschiebung unabhängig vom Datenmuster gleich.

Schauen wir uns nun die von ihnen generierte x86 Assembly genauer an. Der Einfachheit halber verwenden wir zwei Funktionen max1 und max2.

max1 verwendet den bedingten Zweig if... else ...:

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

max2 verwendet den ternären Operator ... ? ... : ...:

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

Auf einem x86-64-Computer generiert GCC -S die folgende Assembly.

: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 verwendet aufgrund der Verwendung des Befehls cmovge viel weniger Code. Der eigentliche Vorteil ist jedoch, dass max2 keine Verzweigungssprünge beinhaltet, jmp, die eine erhebliche Leistungseinbuße zur Folge hätten, wenn das vorhergesagte Ergebnis nicht stimmt.

Warum ist ein bedingter Zug dann besser?

In einem typischen x86 Prozessor ist die Ausführung eines Befehls in mehrere Stufen unterteilt. Grob gesagt haben wir unterschiedliche Hardware, um mit unterschiedlichen Phasen umzugehen. Wir müssen also nicht warten, bis eine Anweisung abgeschlossen ist, um eine neue Anweisung zu beginnen. Dies wird als Pipelining bezeichnet.

In einem Verzweigungsfall wird die folgende Anweisung von der vorhergehenden Anweisung bestimmt, daher können wir kein Pipelining durchführen. Wir müssen entweder warten oder vorhersagen.

In einem Fall des bedingten Verschiebens ist der Befehl zum bedingten Verschieben der Ausführung in mehrere Stufen unterteilt, aber die früheren Stufen wie Fetch und Decode hängen nicht vom Ergebnis der vorherigen Anweisung ab; Nur letztere Stufen benötigen das Ergebnis. Wir warten also einen Bruchteil der Ausführungszeit eines Befehls. Aus diesem Grund ist die bedingte Bewegungsversion langsamer als der Zweig, wenn die Vorhersage einfach ist.

Das Buch Computersysteme: Die Perspektive eines Programmierers, zweite Ausgabe erklärt dies im Detail. Sie können Abschnitt 3.6.6 auf bedingte Verschiebungsanweisungen , das gesamte Kapitel 4 auf Prozessorarchitektur und Abschnitt 5.11.2 auf a überprüfen Sonderbehandlung für Branch Prediction und Misprediction Penalties .

Manchmal können einige moderne Compiler unseren Code für Assembly mit einer besseren Leistung optimieren, manchmal können einige Compiler dies nicht (der fragliche Code verwendet den systemeigenen Compiler von Visual Studio). Wenn Sie den Leistungsunterschied zwischen Verzweigungs- und bedingten Verschiebungen kennen, können Sie Code mit besserer Leistung schreiben, wenn das Szenario so komplex wird, dass der Compiler sie nicht automatisch optimieren kann.

3180
WiSaGaN

Wenn Sie neugierig auf noch mehr Optimierungen sind, die an diesem Code vorgenommen werden können, beachten Sie Folgendes:

Beginnend mit der Originalschleife:

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

Mit dem Schleifenaustausch können wir diese Schleife sicher ändern in:

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

Dann können Sie sehen, dass die Bedingung if während der Ausführung der Schleife i konstant ist, sodass Sie den Ausgang if setzen können:

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

Dann sehen Sie, dass die innere Schleife zu einem einzigen Ausdruck zusammengefasst werden kann, vorausgesetzt, das Gleitkommamodell lässt dies zu (/fp:fast wird beispielsweise ausgelöst).

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

Dieser ist 100.000-mal schneller als zuvor.

2183
vulcan raven

Zweifellos wären einige von uns an Möglichkeiten interessiert, Code zu identifizieren, der für den Branch-Predictor der CPU problematisch ist. Das Valgrind-Tool cachegrind verfügt über einen Verzweigungsvorhersage-Simulator, der mithilfe des Flags --branch-sim=yes aktiviert wird. Wenn Sie die Beispiele in dieser Frage durchgehen und die Anzahl der äußeren Schleifen auf 10000 reduzieren und mit g++ kompilieren, erhalten Sie die folgenden Ergebnisse:

Sortiert:

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

Unsortiert:

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

Ein Drilldown in die zeilenweise Ausgabe von cg_annotate sehen wir für die fragliche Schleife:

Sortiert:

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

Unsortiert:

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

Auf diese Weise können Sie die problematische Zeile leicht identifizieren. In der unsortierten Version verursacht die Zeile if (data[c] >= 128) 164.050.007 falsch vorhergesagte bedingte Verzweigungen (Bcm) unter dem Zweigprädiktor-Modell von cachegrind, während sie in der sortierten Version nur 10.006 verursacht.


Alternativ können Sie unter Linux das Subsystem für Leistungsindikatoren verwenden, um dieselbe Aufgabe auszuführen, jedoch mit nativer Leistung unter Verwendung von CPU-Leistungsindikatoren.

perf stat ./sumtest_sorted

Sortiert:

 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

Unsortiert:

 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

Es kann auch Quellcode-Annotation mit Dissassembly durchführen.

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)
...

Siehe das Tutorial zur Leistung für weitere Details.

1823
caf

Ich habe gerade über diese Frage und ihre Antworten nachgelesen, und ich habe das Gefühl, dass eine Antwort fehlt.

Eine übliche Methode, um die Verzweigungsvorhersage zu beseitigen, die in verwalteten Sprachen besonders gut funktioniert, ist die Tabellensuche anstelle einer Verzweigung (obwohl ich sie in diesem Fall nicht getestet habe).

Dieser Ansatz funktioniert im Allgemeinen, wenn:

  1. es ist eine kleine Tabelle und wird wahrscheinlich im Prozessor zwischengespeichert
  2. sie arbeiten in einer engen Schleife und/oder der Prozessor kann die Daten vorab laden.

Hintergrund und warum

Aus Sicht des Prozessors ist Ihr Speicher langsam. Um den Geschwindigkeitsunterschied auszugleichen, sind einige Caches in Ihren Prozessor integriert (L1/L2-Cache). Stellen Sie sich vor, Sie führen Ihre Nizza-Berechnungen durch und stellen fest, dass Sie ein Stück Gedächtnis benötigen. Der Prozessor erhält seine "Lade" -Operation und lädt das Stück Speicher in den Cache - und verwendet dann den Cache, um den Rest der Berechnungen durchzuführen. Da der Speicher relativ langsam ist, verlangsamt dieser Ladevorgang Ihr Programm.

Ähnlich wie die Verzweigungsvorhersage wurde diese bei den Pentium-Prozessoren optimiert: Der Prozessor sagt voraus, dass ein Datenelement geladen werden muss, und versucht, dieses in den Cache zu laden, bevor die Operation tatsächlich den Cache erreicht. Wie wir bereits gesehen haben, läuft die Verzweigungsvorhersage manchmal furchtbar schief - im schlimmsten Fall müssen Sie zurückgehen und tatsächlich auf eine Speicherladung warten, die ewig dauern wird (mit anderen Worten: Verzweigungsvorhersage schlägt fehl) Schlimm, eine Speicherauslastung nach einem Ausfall der Verzweigungsvorhersage ist einfach schrecklich!).

Glücklicherweise lädt der Prozessor das Speicherzugriffsmuster in seinen schnellen Cache, wenn es vorhersehbar ist, und alles ist in Ordnung.

Das erste, was wir wissen müssen, ist, was klein ist ? Während kleiner im Allgemeinen besser ist, ist es eine Faustregel, sich an Nachschlagetabellen mit einer Größe von <= 4096 Byte zu halten. Als obere Grenze: Wenn Ihre Nachschlagetabelle größer als 64 KB ist, lohnt es sich wahrscheinlich, sie zu überdenken.

Erstellen einer Tabelle

Also haben wir herausgefunden, dass wir einen kleinen Tisch schaffen können. Als Nächstes müssen Sie eine Nachschlagefunktion einrichten. Nachschlagefunktionen sind normalerweise kleine Funktionen, die einige grundlegende Ganzzahloperationen verwenden (und, oder, xoder, verschieben, hinzufügen, entfernen und möglicherweise multiplizieren). Sie möchten, dass Ihre Eingaben von der Suchfunktion in eine Art eindeutigen Schlüssel in Ihrer Tabelle übersetzt werden, der Ihnen dann einfach die Antwort auf alle von Ihnen gewünschten Aufgaben gibt.

In diesem Fall:> = 128 bedeutet, dass wir den Wert behalten können, <128 bedeutet, dass wir ihn entfernen. Am einfachsten geht das mit einem UND: Wenn wir es behalten, UND mit 7FFFFFFF; Wenn wir es loswerden wollen, machen wir UND es mit 0. Beachten Sie auch, dass 128 eine Potenz von 2 ist - also können wir eine Tabelle mit 32768/128 ganzen Zahlen erstellen und sie mit einer Null und viel füllen 7FFFFFFFF's.

Verwaltete Sprachen

Sie fragen sich vielleicht, warum dies in verwalteten Sprachen gut funktioniert. Schließlich überprüfen verwaltete Sprachen die Grenzen der Arrays mit einer Verzweigung, um sicherzustellen, dass Sie nichts falsch machen ...

Na ja, nicht genau ... :-)

Es wurde viel daran gearbeitet, diesen Zweig für verwaltete Sprachen zu entfernen. Zum Beispiel:

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

In diesem Fall ist es für den Compiler offensichtlich, dass die Randbedingung niemals erreicht wird. Zumindest der Microsoft JIT-Compiler (aber ich gehe davon aus, dass Java ähnliche Aktionen ausführt) wird dies bemerken und die Prüfung insgesamt entfernen. WOW, das heißt kein Zweig. Ebenso wird es sich um andere offensichtliche Fälle handeln.

Wenn Sie Probleme mit Suchvorgängen in verwalteten Sprachen haben, müssen Sie Ihrer Suchfunktion einen & 0x[something]FFF hinzufügen, um die Grenzüberprüfung vorhersehbar zu machen.

Das Ergebnis dieses Falls

// 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();
1281
atlaste

Da die Daten beim Sortieren des Arrays zwischen 0 und 255 verteilt werden, wird in der ersten Hälfte der Iterationen die if- -Anweisung nicht eingegeben (die if -Anweisung wird unten geteilt).

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

Die Frage ist: Was führt dazu, dass die obige Anweisung in bestimmten Fällen nicht ausgeführt wird, wie bei sortierten Daten? Hier kommt der "Branch Predictor". Ein Verzweigungsprädiktor ist eine digitale Schaltung, die zu erraten versucht, in welche Richtung eine Verzweigung (z. B. eine if-then-else -Struktur) gehen wird, bevor dies sicher bekannt ist. Der Zweck des Verzweigungsprädiktors besteht darin, den Fluss in der Anweisungspipeline zu verbessern. Branch Predictors spielen eine entscheidende Rolle bei der Erzielung einer hohen effektiven Leistung!

Lassen Sie uns einige Benchmarking machen, um es besser zu verstehen

Die Leistung einer if -Anweisung hängt davon ab, ob ihr Zustand ein vorhersagbares Muster aufweist. Wenn die Bedingung immer wahr oder immer falsch ist, nimmt die Verzweigungsvorhersage-Logik im Prozessor das Muster auf. Wenn andererseits das Muster nicht vorhersehbar ist, ist die if- Anweisung viel teurer.

Lassen Sie uns die Leistung dieser Schleife unter verschiedenen Bedingungen messen:

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

Hier sind die Timings der Schleife mit verschiedenen True-False-Mustern:

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

Ein "bad" true-false-Muster kann eine if- Anweisung bis zu sechsmal langsamer machen als ein "good" - Muster! Welches Muster gut und welches schlecht ist, hängt natürlich von den genauen Anweisungen ab, die der Compiler und der jeweilige Prozessor generiert haben.

Es besteht also kein Zweifel über die Auswirkung der Branchenvorhersage auf die Leistung!

1148
Saqlain

Eine Möglichkeit, Fehler bei der Verzweigungsvorhersage zu vermeiden, besteht darin, eine Nachschlagetabelle zu erstellen und anhand der Daten zu indizieren. Stefan de Bruijn hat das in seiner Antwort besprochen.

In diesem Fall wissen wir jedoch, dass die Werte im Bereich [0, 255] liegen, und es geht uns nur um Werte> = 128. Das bedeutet, dass wir leicht ein einzelnes Bit extrahieren können, das uns sagt, ob wir einen Wert wollen oder nicht: durch Verschieben Bei den Daten rechts von 7 Bits bleibt ein 0-Bit oder ein 1-Bit übrig, und wir möchten den Wert nur dann hinzufügen, wenn wir ein 1-Bit haben. Nennen wir dieses Bit das "Entscheidungsbit".

Durch Verwendung des 0/1-Werts des Entscheidungsbits als Index in einem Array können wir Code erstellen, der gleich schnell ist, unabhängig davon, ob die Daten sortiert sind oder nicht. Unser Code addiert immer einen Wert, aber wenn das Entscheidungsbit 0 ist, addieren wir den Wert an einer Stelle, die uns egal ist. Hier ist der Code:

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

Dieser Code verschwendet die Hälfte der Adds, hat jedoch nie einen Fehler bei der Verzweigungsvorhersage. Es ist bei zufälligen Daten erheblich schneller als bei der Version mit einer tatsächlichen if-Anweisung.

In meinen Tests war eine explizite Nachschlagetabelle jedoch etwas schneller als diese, wahrscheinlich weil die Indizierung in eine Nachschlagetabelle etwas schneller war als die Bitverschiebung. Dies zeigt, wie mein Code die Nachschlagetabelle einrichtet und verwendet (einfallslos lut für "LookUp Table" im Code). Hier ist der C++ Code:

// 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 diesem Fall war die Nachschlagetabelle nur 256 Byte groß, passt also gut in einen Cache und alles war schnell. Diese Technik würde nicht gut funktionieren, wenn die Daten 24-Bit-Werte wären und wir nur die Hälfte von ihnen wollten ... Die Nachschlagetabelle wäre viel zu groß, um praktisch zu sein. Andererseits können wir die beiden oben gezeigten Techniken kombinieren: Zuerst die Bits verschieben, dann eine Nachschlagetabelle indizieren. Für einen 24-Bit-Wert, für den wir nur den Wert der oberen Hälfte wünschen, können wir die Daten möglicherweise um 12 Bit nach rechts verschieben und einen 12-Bit-Wert für einen Tabellenindex beibehalten. Ein 12-Bit-Tabellenindex impliziert eine Tabelle mit 4096 Werten, was möglicherweise sinnvoll ist.

Anstatt eine if -Anweisung zu verwenden, kann die Technik der Indizierung in ein Array verwendet werden, um zu entscheiden, welcher Zeiger verwendet werden soll. Ich sah eine Bibliothek, die binäre Bäume implementierte, und anstatt zwei benannte Zeiger (pLeft und pRight oder was auch immer) zu haben, hatte ich ein Array von Zeigern der Länge 2 und benutzte die "Entscheidungsbit" -Technik, um zu entscheiden, welche eine zu folgen. Zum Beispiel anstelle von:

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

diese Bibliothek würde etwa Folgendes tun:

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

Hier ist ein Link zu diesem Code: Red Black Trees , Ewig verwirrt

1069
steveha

Im sortierten Fall können Sie sich besser als auf eine erfolgreiche Verzweigungsvorhersage oder einen beliebigen verzweigungslosen Vergleichstrick verlassen: Entfernen Sie die Verzweigung vollständig.

In der Tat ist das Array in einer zusammenhängenden Zone mit data < 128 und einer anderen mit data >= 128 unterteilt. Sie sollten den Partitionspunkt also mit dichotomische Suche (unter Verwendung von Lg(arraySize) = 15 -Vergleichen) finden und von diesem Punkt aus eine gerade Akkumulation durchführen.

So etwas wie (nicht markiert)

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

oder etwas mehr verschleiert

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

Ein noch schnellerer Ansatz, der eine ungefähre Lösung sowohl für sortierte als auch für unsortierte gibt, ist: sum= 3137536; (unter der Annahme einer wirklich gleichmäßigen Verteilung, 16384 Proben mit erwarteter Wert 191,5) : -)

966
Yves Daoust

Das obige Verhalten tritt aufgrund der Verzweigungsvorhersage auf.

Um die Verzweigungsvorhersage zu verstehen, muss man zuerst die Anweisungspipeline verstehen :

Jede Anweisung ist in eine Folge von Schritten unterteilt, so dass verschiedene Schritte gleichzeitig parallel ausgeführt werden können. Diese Technik ist als Anweisungs-Pipeline bekannt und wird verwendet, um den Durchsatz in modernen Prozessoren zu erhöhen. Um dies besser zu verstehen, sehen Sie sich das an Beispiel auf Wikipedia .

Im Allgemeinen haben moderne Prozessoren ziemlich lange Pipelines, aber der Einfachheit halber betrachten wir nur diese 4 Schritte.

  1. IF - Holt die Anweisung aus dem Speicher
  2. ID - Dekodiere die Anweisung
  3. EX - Führen Sie die Anweisung aus
  4. WB - Zurückschreiben in das CPU-Register

4-stufige Pipeline im Allgemeinen für 2 Anweisungen.  4-stage pipeline in general

Wenn wir zu der obigen Frage zurückkehren, betrachten wir die folgenden Anweisungen:

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

Ohne Verzweigungsvorhersage würde Folgendes eintreten:

Um die Anweisung B oder die Anweisung C auszuführen, muss der Prozessor warten, bis die Anweisung A die EX-Stufe in der Pipeline nicht erreicht, da die Entscheidung, zur Anweisung B oder zur Anweisung C zu gehen, vom Ergebnis der Anweisung A abhängt wird so aussehen.

Wenn die Bedingung true zurückgibt:  enter image description here

Wenn if condition false zurückgibt:  enter image description here

Infolge des Wartens auf das Ergebnis des Befehls A beträgt die Gesamtzahl der CPU-Zyklen, die in dem obigen Fall (ohne Verzweigungsvorhersage; sowohl für wahr als auch falsch) verbracht wurden, 7.

Was ist also eine Verzweigungsvorhersage?

Die Verzweigungsvorhersage versucht zu erraten, in welche Richtung eine Verzweigung (eine Wenn-Dann-Sonst-Struktur) gehen wird, bevor dies sicher bekannt ist. Es wird nicht warten, bis der Befehl A die EX-Stufe der Pipeline erreicht, aber es wird die Entscheidung erraten und zu diesem Befehl übergehen (B oder C im Fall unseres Beispiels).

Im Falle einer korrekten Vermutung sieht die Pipeline ungefähr so ​​aus:  enter image description here

Wenn später festgestellt wird, dass die Vermutung falsch war, werden die teilweise ausgeführten Anweisungen verworfen, und die Pipeline beginnt mit der richtigen Verzweigung von vorne, was zu einer Verzögerung führt. Die Zeit, die im Falle einer Verzweigungsfehlvorhersage verschwendet wird, ist gleich der Anzahl von Stufen in der Pipeline von der Abrufstufe zur Ausführungsstufe. Moderne Mikroprozessoren neigen dazu, ziemlich lange Pipelines zu haben, so dass die Fehlvorhersageverzögerung zwischen 10 und 20 Taktzyklen liegt. Je länger die Pipeline ist, desto größer ist der Bedarf an einem guten Verzweigungsvorhersage .

Im OP-Code hat der Verzweigungs-Prädiktor zum ersten Mal, wenn die Bedingung erfüllt ist, keine Informationen, um die Vorhersage zu begründen, so dass er beim ersten Mal zufällig den nächsten Befehl auswählt. Später in der for-Schleife kann die Vorhersage auf dem Verlauf basieren. Für ein Array, das in aufsteigender Reihenfolge sortiert ist, gibt es drei Möglichkeiten:

  1. Alle Elemente sind weniger als 128
  2. Alle Elemente sind größer als 128
  3. Einige neue Startelemente sind kleiner als 128 und werden später größer als 128

Nehmen wir an, dass der Prädiktor beim ersten Durchlauf immer den wahren Zweig annimmt.

Im ersten Fall wird es also immer den wahren Zweig nehmen, da historisch gesehen alle seine Vorhersagen korrekt sind. Im zweiten Fall wird zunächst eine falsche Vorhersage getroffen, nach einigen Iterationen jedoch eine korrekte Vorhersage. Im dritten Fall wird zunächst eine korrekte Vorhersage getroffen, bis die Anzahl der Elemente unter 128 liegt. Danach wird eine Zeit lang ein Fehler auftreten und die Korrektur selbst, wenn in der Historie ein Fehler bei der Verzweigungsvorhersage auftritt.

In all diesen Fällen ist die Anzahl der Fehler zu gering. Infolgedessen müssen die teilweise ausgeführten Anweisungen nur einige Male verworfen und mit der richtigen Verzweigung neu gestartet werden, was zu weniger CPU-Zyklen führt.

Im Fall eines zufälligen unsortierten Arrays muss die Vorhersage die teilweise ausgeführten Anweisungen verwerfen und die meiste Zeit mit dem richtigen Zweig von vorne beginnen, was im Vergleich zum sortierten Array zu mehr CPU-Zyklen führt.

789
Harsh Sharma

Eine offizielle Antwort wäre von

  1. Intel - Vermeidung der Kosten von Branchenfehlern
  2. Intel - Reorganisation von Zweigen und Schleifen, um Fehlvorhersagen vorzubeugen
  3. Wissenschaftliche Arbeiten - Branch Prediction Computer Architecture
  4. Bücher: J. L. Hennessy, D.A. Patterson: Computerarchitektur: ein quantitativer Ansatz
  5. Artikel in wissenschaftlichen Publikationen: T.Y. Yeh, Y.N. Patt machte eine Menge davon auf Branchenvorhersagen.

Sie können auch an diesem schönen Diagramm sehen, warum der Verzweigungsprädiktor verwirrt wird.

2-bit state diagram

Jedes Element im Originalcode ist ein Zufallswert

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

der Prädiktor wechselt also die Seite, wenn std::Rand() bläst.

Auf der anderen Seite wird der Prädiktor, sobald er sortiert ist, zuerst in einen Zustand versetzt, in dem er stark nicht genommen ist, und wenn sich die Werte auf den hohen Wert ändern, ändert sich der Prädiktor in drei Durchläufen von stark nicht genommen zu stark genommen.


691
Surt

In der gleichen Zeile (ich denke, dies wurde durch keine Antwort hervorgehoben) ist es gut zu erwähnen, dass manchmal (insbesondere in Software, bei der die Leistung von Bedeutung ist - wie im Linux-Kernel) einige if-Anweisungen wie die folgende zu finden sind:

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

oder ähnlich:

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

Sowohl likely() als auch unlikely() sind tatsächlich Makros, die unter Verwendung von etwas wie dem __builtin_expect des GCC definiert werden, um dem Compiler zu helfen, Vorhersagecode einzufügen, um die Bedingung unter Berücksichtigung der vom Benutzer bereitgestellten Informationen zu begünstigen . GCC unterstützt andere integrierte Funktionen, die das Verhalten des laufenden Programms ändern oder Anweisungen auf niedriger Ebene wie das Löschen des Caches usw. ausgeben können. Siehe diese Dokumentation , das die verfügbaren integrierten Funktionen von GCC durchläuft.

Normalerweise finden sich Optimierungen dieser Art hauptsächlich in Echtzeitanwendungen oder in eingebetteten Systemen, bei denen die Ausführungszeit eine Rolle spielt und diese entscheidend ist. Wenn Sie beispielsweise nach Fehlern suchen, die nur 1/10000000 Mal auftreten, können Sie den Compiler darüber informieren. Auf diese Weise würde die Verzweigungsvorhersage standardmäßig davon ausgehen, dass die Bedingung falsch ist.

660
rkachach

Häufig verwendete boolesche Operationen in C++ erzeugen viele Verzweigungen im kompilierten Programm. Wenn sich diese Zweige in Schleifen befinden und schwer vorherzusagen sind, können sie die Ausführung erheblich verlangsamen. Boolesche Variablen werden als 8-Bit-Ganzzahlen mit den Werten 0 für false und 1 für true gespeichert.

Boolesche Variablen sind in dem Sinne überbestimmt, dass alle Operatoren, die Boolesche Variablen als Eingabe haben, prüfen, ob die Eingaben einen anderen Wert als 0 oder 1 haben, aber Operatoren, die Boolesche Werte als Ausgabe haben, keinen anderen Wert als erzeugen können 0 oder 1. Dies macht Operationen mit Booleschen Variablen als Eingabe weniger effizient als nötig. Beispiel betrachten:

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

Dies wird normalerweise vom Compiler folgendermaßen implementiert:

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

Dieser Code ist alles andere als optimal. Bei falschen Vorhersagen können die Zweige sehr lange dauern. Die Booleschen Operationen können viel effizienter gestaltet werden, wenn mit Sicherheit bekannt ist, dass die Operanden keine anderen Werte als 0 und 1 haben. Der Compiler geht nicht davon aus, dass die Variablen andere Werte haben könnten, wenn sie nicht initialisiert sind oder aus unbekannten Quellen stammen. Der obige Code kann optimiert werden, wenn a und b mit gültigen Werten initialisiert wurden oder von Operatoren stammen, die eine boolesche Ausgabe erzeugen. Der optimierte Code sieht folgendermaßen aus:

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

char wird anstelle von bool verwendet, um die Verwendung der bitweisen Operatoren (& und |) anstelle der Booleschen Operatoren (&& und zu ermöglichen ||). Die bitweisen Operatoren sind einzelne Anweisungen, die nur einen Taktzyklus benötigen. Der Operator OR (|) funktioniert auch dann, wenn a und b andere Werte als 0 oder 1 haben. Der Operator AND (&) und der Operator EXCLUSIVE OR (^) führen möglicherweise zu inkonsistenten Ergebnissen, wenn die Operanden andere Werte als 0 und 1 haben. .

~ kann nicht für NOT verwendet werden. Stattdessen können Sie einen Booleschen Wert NOT für eine Variable erstellen, von der bekannt ist, dass sie 0 oder 1 ist, indem Sie sie mit 1 XOREN:

bool a, b;
b = !a;

kann optimiert werden für:

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

a && b kann nicht durch a & b ersetzt werden, wenn b ein Ausdruck ist, der nicht ausgewertet werden sollte, wenn a ist false (&& wird nicht ausgewertet b, & wird). Ebenso kann a || b nicht durch a | b ersetzt werden, wenn b ein Ausdruck ist, der nicht ausgewertet werden sollte, wenn atrue ist.

Die Verwendung bitweiser Operatoren ist vorteilhafter, wenn die Operanden Variablen sind, als wenn die Operanden Vergleiche sind:

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

ist in den meisten Fällen optimal (es sei denn, Sie erwarten, dass der Ausdruck && viele Verzweigungsfehlvorhersagen generiert).

630
Maciej

Das ist sicher!...

Die Verzweigungsvorhersage verlangsamt die Logik, da der Code umgeschaltet wird! Es ist, als würden Sie eine gerade Straße oder eine Straße mit vielen Abbiegungen fahren, sicher wird die gerade Straße schneller gemacht! ...

Wenn das Array sortiert ist, ist Ihre Bedingung im ersten Schritt falsch: data[c] >= 128, dann wird ein wahrer Wert für den gesamten Weg bis zum Ende der Straße. So kommen Sie schneller ans Ende der Logik. Auf der anderen Seite benötigen Sie bei Verwendung eines unsortierten Arrays viel Drehen und Verarbeiten, wodurch Ihr Code mit Sicherheit langsamer ausgeführt wird ...

Schauen Sie sich das Bild an, das ich unten für Sie erstellt habe. Welche Straße wird schneller fertig?

Branch Prediction

Programmatisch bewirkt die Verzweigungsvorhersage , dass der Prozess langsamer wird ...

Am Ende ist es auch gut zu wissen, dass wir zwei Arten von Verzweigungsvorhersagen haben, von denen jede Ihren Code unterschiedlich beeinflusst:

1. Statisch

2. Dynamisch

Branch Prediction

Die statische Verzweigungsvorhersage wird vom Mikroprozessor verwendet, wenn zum ersten Mal eine bedingte Verzweigung auftritt, und die dynamische Verzweigungsvorhersage wird zum erfolgreichen Ausführen des bedingten Verzweigungscodes verwendet.

Um Ihren Code effektiv zu schreiben und diese Regeln zu nutzen, müssen Sie beim Schreiben von if-else oder switch Anweisungen, überprüfen Sie die häufigsten Fälle zuerst und arbeiten Sie schrittweise bis zu den am wenigsten verbreiteten. Schleifen erfordern nicht unbedingt eine spezielle Codereihenfolge für die statische Verzweigungsvorhersage, da normalerweise nur die Bedingung des Schleifeniterators verwendet wird.

302
Alireza

Diese Frage wurde bereits mehrfach ausgezeichnet beantwortet. Dennoch möchte ich die Aufmerksamkeit der Gruppe auf eine weitere interessante Analyse lenken.

Kürzlich wurde dieses Beispiel (sehr leicht modifiziert) auch verwendet, um zu demonstrieren, wie ein Teil des Codes innerhalb des Programms selbst unter Windows profiliert werden kann. Unterwegs zeigt der Autor auch, wie anhand der Ergebnisse ermittelt wird, wo der Code die meiste Zeit sowohl im sortierten als auch im unsortierten Fall verbringt. Schließlich zeigt das Stück auch, wie man ein wenig bekanntes Merkmal der HAL (Hardware Abstraction Layer) verwendet, um festzustellen, wie viel Verzweigungsfehler in dem unsortierten Fall vorkommen.

Der Link ist hier: http://www.geoffchappell.com/studies/windows/km/ntoskrnl/api/ex/profile/demo.htm

274
ForeverLearning

Wie bereits von anderen erwähnt, steckt hinter dem Rätsel Branch Predictor .

Ich versuche nicht, etwas hinzuzufügen, sondern das Konzept auf eine andere Weise zu erklären. Es gibt eine kurze Einführung in das Wiki, die Text und Diagramme enthält. Die folgende Erklärung gefällt mir. Sie verwendet ein Diagramm, um den Verzweigungsvorhersage-Modus intuitiv zu erläutern.

In der Computerarchitektur ist ein Verzweigungsprädiktor eine digitale Schaltung, die zu erraten versucht, in welche Richtung eine Verzweigung (z. B. eine Wenn-Dann-Sonst-Struktur) gehen wird, bevor dies sicher bekannt ist. Der Zweck des Verzweigungsprädiktors besteht darin, den Fluss in der Anweisungspipeline zu verbessern. Verzweigungsvorhersagen spielen eine entscheidende Rolle bei der Erzielung einer hohen effektiven Leistung in vielen modernen Pipeline-Mikroprozessorarchitekturen wie x86.

Die bidirektionale Verzweigung wird normalerweise mit einem bedingten Sprungbefehl implementiert. Ein bedingter Sprung kann entweder "nicht ausgeführt" werden und die Ausführung mit dem ersten Codezweig fortsetzen, der unmittelbar nach dem bedingten Sprung folgt, oder er kann "ausgeführt" werden und zu einer anderen Stelle im Programmspeicher springen, an der sich der zweite Codezweig befindet gelagert. Es ist nicht sicher bekannt, ob ein bedingter Sprung ausgeführt wird oder nicht, bis die Bedingung berechnet wurde und der bedingte Sprung die Ausführungsphase in der Anweisungspipeline durchlaufen hat (siehe 1).

figure 1

Basierend auf dem beschriebenen Szenario habe ich eine Animationsdemo geschrieben, um zu zeigen, wie Anweisungen in einer Pipeline in verschiedenen Situationen ausgeführt werden.

  1. Ohne den Branch Predictor.

Ohne Verzweigungsvorhersage müsste der Prozessor warten, bis der bedingte Sprungbefehl die Ausführungsstufe passiert hat, bevor der nächste Befehl in die Abrufstufe in der Pipeline eintreten kann.

Das Beispiel enthält drei Anweisungen und die erste ist eine bedingte Sprunganweisung. Die letzten beiden Befehle können in die Pipeline gehen, bis der bedingte Sprungbefehl ausgeführt wird.

without branch predictor

Es dauert 9 Taktzyklen, bis 3 Anweisungen abgeschlossen sind.

  1. Verwenden Sie Branch Predictor und machen Sie keinen bedingten Sprung. Nehmen wir an, dass die Vorhersage nicht den bedingten Sprung macht .

enter image description here

Es dauert 7 Taktzyklen, bis 3 Anweisungen abgeschlossen sind.

  1. Verwenden Sie Branch Predictor und machen Sie einen bedingten Sprung. Nehmen wir an, dass die Vorhersage nicht den bedingten Sprung macht .

enter image description here

Es dauert 9 Taktzyklen, bis 3 Anweisungen abgeschlossen sind.

Die Zeit, die im Falle einer Verzweigungsfehlvorhersage verschwendet wird, ist gleich der Anzahl von Stufen in der Pipeline von der Abrufstufe zur Ausführungsstufe. Moderne Mikroprozessoren neigen dazu, ziemlich lange Pipelines zu haben, so dass die Fehlvorhersageverzögerung zwischen 10 und 20 Taktzyklen liegt. Infolgedessen erhöht das Verlängern einer Pipeline den Bedarf an einem fortgeschritteneren Verzweigungsprädiktor.

Wie Sie sehen, haben wir anscheinend keinen Grund, Branch Predictor nicht zu verwenden.

Es ist eine ziemlich einfache Demo, die den grundlegenden Teil von Branch Predictor erklärt. Wenn diese Gifs ärgerlich sind, können Sie sie aus der Antwort entfernen. Besucher können die Demo auch von BranchPredictorDemo herunterladen

200
Gearon

Verzweigungsvorhersagegewinn!

Es ist wichtig zu verstehen, dass die falsche Vorhersage von Zweigen Programme nicht verlangsamt. Die Kosten für eine verpasste Vorhersage sind so, als ob keine Verzweigungsvorhersage vorhanden wäre, und Sie haben auf die Auswertung des Ausdrucks gewartet, um zu entscheiden, welcher Code ausgeführt werden soll (weitere Erläuterungen im nächsten Absatz).

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

Immer wenn eine if-else\switch -Anweisung vorhanden ist, muss der Ausdruck ausgewertet werden, um zu bestimmen, welcher Block ausgeführt werden soll. In den vom Compiler generierten Assembly-Code werden bedingte branch -Anweisungen eingefügt.

Eine Verzweigungsanweisung kann dazu führen, dass ein Computer mit der Ausführung einer anderen Anweisungssequenz beginnt und damit von seinem Standardverhalten abweicht, Anweisungen in der angegebenen Reihenfolge auszuführen (dh, wenn der Ausdruck falsch ist, überspringt das Programm den Code des Blocks if in Abhängigkeit von eine Bedingung, die in unserem Fall der Ausdruck Bewertung ist.

Abgesehen davon versucht der Compiler, das Ergebnis vorherzusagen, bevor es tatsächlich ausgewertet wird. Es ruft Anweisungen aus dem Block if ab, und wenn sich herausstellt, dass der Ausdruck wahr ist, dann wunderbar! Wir haben die Zeit gewonnen, die für die Evaluierung erforderlich war, und Fortschritte im Code erzielt. Andernfalls wird der falsche Code ausgeführt, die Pipeline wird geleert und der richtige Block ausgeführt.

Visualisierung:

Angenommen, Sie müssen Route 1 oder Route 2 auswählen. Warten, bis Ihr Partner die Karte überprüft hat. Sie haben bei ## angehalten und gewartet, oder Sie können einfach Route 1 auswählen und wenn Sie Glück haben (Route 1 ist die richtige Route). Dann mussten Sie nicht warten, bis Ihr Partner die Karte überprüft hat (Sie haben die Zeit gespart, die er für die Überprüfung der Karte benötigt hätte), sonst kehren Sie einfach zurück.

Während das Spülen von Pipelines superschnell ist, lohnt es sich heutzutage, dieses Glücksspiel zu spielen. Das Vorhersagen sortierter oder sich langsam ändernder Daten ist immer einfacher und besser als das Vorhersagen schneller Änderungen.

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

Es geht um Branchenvorhersage. Was ist es?

  • Ein Branch Predictor ist eine der alten Techniken zur Leistungsverbesserung, die in modernen Architekturen immer noch Relevanz findet. Während die einfachen Vorhersagetechniken eine schnelle Suche und Energieeffizienz bieten, leiden sie unter einer hohen Fehlerrate.

  • Auf der anderen Seite bieten komplexe Verzweigungsvorhersagen - entweder neuronale Vorhersagen oder Varianten der Zweigvorhersagen auf zwei Ebenen - eine bessere Vorhersagegenauigkeit, verbrauchen jedoch mehr Leistung und die Komplexität nimmt exponentiell zu.

  • Darüber hinaus ist bei komplexen Vorhersagetechniken die Zeit für die Vorhersage der Verzweigungen selbst sehr hoch und liegt zwischen 2 und 5 Zyklen, was mit der Ausführungszeit der tatsächlichen Verzweigungen vergleichbar ist.

  • Die Verzweigungsvorhersage ist im Wesentlichen ein Optimierungs Minimierungs-) Problem, bei dem der Schwerpunkt auf der Erzielung einer niedrigstmöglichen Fehlerrate, eines geringen Energieverbrauchs und einer geringen Komplexität mit minimalen Ressourcen liegt.

Es gibt wirklich drei verschiedene Arten von Zweigen:

Weiterleitungsbedingte Verzweigungen - Basierend auf einer Laufzeitbedingung wird der PC (Programmzähler) so geändert, dass er auf eine Weiterleitungsadresse im Befehlsstrom zeigt.

Rückwärtsbedingte Verzweigungen - Der PC wird so geändert, dass er im Befehlsstrom rückwärts zeigt. Die Verzweigung basiert auf einer Bedingung, z. B. einer Rückwärtsverzweigung zum Anfang einer Programmschleife, wenn ein Test am Ende der Schleife angibt, dass die Schleife erneut ausgeführt werden soll.

Unbedingte Verzweigungen - Dies schließt Sprünge, Prozeduraufrufe und Rückgaben ein, die keine bestimmte Bedingung haben. Beispielsweise könnte ein unbedingter Sprungbefehl in der Assemblersprache einfach als "jmp" codiert werden, und der Befehlsstrom muss sofort zu dem Zielort geleitet werden, auf den der Sprungbefehl verweist, wohingegen ein bedingter Sprung als "jmpne" codiert werden könnte. würde den Befehlsstrom nur umleiten, wenn das Ergebnis eines Vergleichs von zwei Werten in einem vorherigen "Vergleich" -Befehl anzeigt, dass die Werte nicht gleich sind. (Das von der x86-Architektur verwendete segmentierte Adressierungsschema erhöht die Komplexität, da Sprünge entweder "nah" (innerhalb eines Segments) oder "fern" (außerhalb des Segments) sein können. Jeder Typ hat unterschiedliche Auswirkungen auf Algorithmen zur Verzweigungsvorhersage.)

Statische/dynamische Verzweigungsvorhersage : Die statische Verzweigungsvorhersage wird vom Mikroprozessor verwendet, wenn zum ersten Mal eine bedingte Verzweigung auftritt, und die dynamische Verzweigungsvorhersage wird für nachfolgende Ausführungen verwendet des bedingten Verzweigungscodes.

Verweise:

123
Farhad

Neben der Tatsache, dass die Verzweigungsvorhersage Sie verlangsamen kann, hat ein sortiertes Array einen weiteren Vorteil:

Sie können eine Stoppbedingung festlegen, anstatt nur den Wert zu überprüfen. Auf diese Weise durchlaufen Sie nur die relevanten Daten und ignorieren den Rest.
Die Verzweigungsvorhersage wird nur einmal fehlen.

 // 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];               
 }
117
Yochai Timmer

In ARM ist keine Verzweigung erforderlich, da jeder Befehl über ein 4-Bit-Bedingungsfeld verfügt, das bei Nullkosten getestet wird. Dadurch sind keine kurzen Verzweigungen mehr erforderlich, und es würde kein Treffer für die Verzweigungsvorhersage erzielt. Daher würde die sortierte Version aufgrund des zusätzlichen Sortieraufwands langsamer als die unsortierte Version in ARM ausgeführt. Die innere Schleife würde ungefähr so ​​aussehen:

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

Sortierte Arrays werden aufgrund eines Phänomens, das als Verzweigungsvorhersage bezeichnet wird, schneller verarbeitet als nicht sortierte Arrays.

Der Verzweigungsprädiktor ist eine digitale Schaltung (in Computerarchitektur), die versucht, vorherzusagen, in welche Richtung eine Verzweigung gehen wird, wodurch der Fluss in der Anweisungspipeline verbessert wird. Die Schaltung/der Computer sagt den nächsten Schritt voraus und führt ihn aus.

Wenn Sie eine falsche Vorhersage treffen, kehren Sie zum vorherigen Schritt zurück und führen eine andere Vorhersage durch. Vorausgesetzt, die Vorhersage ist korrekt, fährt der Code mit dem nächsten Schritt fort. Eine falsche Vorhersage führt dazu, dass derselbe Schritt wiederholt wird, bis eine korrekte Vorhersage erfolgt.

Die Antwort auf Ihre Frage ist sehr einfach.

In einem unsortierten Array macht der Computer mehrere Vorhersagen, was zu einer erhöhten Fehlerwahrscheinlichkeit führt. In einem sortierten Array macht der Computer weniger Vorhersagen, wodurch die Wahrscheinlichkeit von Fehlern verringert wird. Um mehr Vorhersagen zu treffen, ist mehr Zeit erforderlich.

Sortiertes Array: Gerade Straße

Unsortiertes Array: Gebogene Straße

______   ________
|     |__|

Astvorhersage: Erraten/Vorhersagen, welche Straße gerade ist, und Folgen ohne Überprüfung

___________________________________________ Straight road
 |_________________________________________|Longer road

Obwohl beide Straßen dasselbe Ziel erreichen, ist die gerade Straße kürzer und die andere länger. Wenn Sie dann versehentlich die andere Straße wählen, gibt es kein Zurück und Sie verlieren zusätzliche Zeit, wenn Sie sich für die längere Straße entscheiden. Dies ähnelt dem, was im Computer geschieht, und ich hoffe, dies hat Ihnen das Verständnis erleichtert.


Ich möchte auch @ Simon_Weaver aus den Kommentaren zitieren:

Es werden nicht weniger Vorhersagen gemacht - es werden weniger falsche Vorhersagen gemacht. Es muss sich noch für jedes Mal durch die Schleife prognostizieren ...

104
Omkaar.K

Die Annahme durch andere Antworten, dass man die Daten sortieren muss, ist nicht richtig.

Der folgende Code sortiert nicht das gesamte Array, sondern nur 200-Element-Segmente und wird dadurch am schnellsten ausgeführt.

Durch das Sortieren nur von Abschnitten mit k Elementen wird die Vorverarbeitung in der linearen Zeit O(n) und nicht in der zum Sortieren des gesamten Arrays erforderlichen O(n.log(n)) abgeschlossen.

#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;
}

Dies "beweist" auch, dass es nichts mit algorithmischen Problemen wie der Sortierreihenfolge zu tun hat, und es handelt sich in der Tat um eine Verzweigungsvorhersage.

26
user2297550

Weil es sortiert ist!

Es ist einfacher, geordnete Daten abzurufen und zu bearbeiten als ungeordnete.

So wie ich Kleidung aus Geschäften (bestellt) und aus meinem Kleiderschrank (durcheinander) auswähle.

0
Arun Joshla