it-swarm.com.de

Das Ersetzen eines 32-Bit-Schleifenzählers durch 64-Bit führt zu verrückten Leistungsabweichungen

Ich war auf der Suche nach dem schnellsten Weg, große Datenmengen zu popcount. Ich habe einen sehr seltsamen Effekt festgestellt: Durch Ändern der Schleifenvariablen von unsigned in uint64_t ist die Leistung auf meinem PC um 50% gesunken.

Der Benchmark

#include <iostream>
#include <chrono>
#include <x86intrin.h>

int main(int argc, char* argv[]) {

    using namespace std;
    if (argc != 2) {
       cerr << "usage: array_size in MB" << endl;
       return -1;
    }

    uint64_t size = atol(argv[1])<<20;
    uint64_t* buffer = new uint64_t[size/8];
    char* charbuffer = reinterpret_cast<char*>(buffer);
    for (unsigned i=0; i<size; ++i)
        charbuffer[i] = Rand()%256;

    uint64_t count,duration;
    chrono::time_point<chrono::system_clock> startP,endP;
    {
        startP = chrono::system_clock::now();
        count = 0;
        for( unsigned k = 0; k < 10000; k++){
            // Tight unrolled loop with unsigned
            for (unsigned i=0; i<size/8; i+=4) {
                count += _mm_popcnt_u64(buffer[i]);
                count += _mm_popcnt_u64(buffer[i+1]);
                count += _mm_popcnt_u64(buffer[i+2]);
                count += _mm_popcnt_u64(buffer[i+3]);
            }
        }
        endP = chrono::system_clock::now();
        duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
        cout << "unsigned\t" << count << '\t' << (duration/1.0E9) << " sec \t"
             << (10000.0*size)/(duration) << " GB/s" << endl;
    }
    {
        startP = chrono::system_clock::now();
        count=0;
        for( unsigned k = 0; k < 10000; k++){
            // Tight unrolled loop with uint64_t
            for (uint64_t i=0;i<size/8;i+=4) {
                count += _mm_popcnt_u64(buffer[i]);
                count += _mm_popcnt_u64(buffer[i+1]);
                count += _mm_popcnt_u64(buffer[i+2]);
                count += _mm_popcnt_u64(buffer[i+3]);
            }
        }
        endP = chrono::system_clock::now();
        duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
        cout << "uint64_t\t"  << count << '\t' << (duration/1.0E9) << " sec \t"
             << (10000.0*size)/(duration) << " GB/s" << endl;
    }

    free(charbuffer);
}

Wie Sie sehen, erstellen wir einen Puffer mit zufälligen Daten mit der Größe x Megabyte, wobei x von der Befehlszeile gelesen wird. Anschließend durchlaufen wir den Puffer und verwenden eine nicht gerollte Version von x86 popcount, um den Popcount durchzuführen. Um ein genaueres Ergebnis zu erhalten, führen wir die Zählung 10.000 Mal durch. Wir messen die Zeiten für die Popcount. Im Großbuchstaben ist die innere Schleifenvariable unsigned, im Kleinbuchstaben ist die innere Schleifenvariable uint64_t. Ich dachte, dass dies keinen Unterschied machen sollte, aber das Gegenteil ist der Fall.

Das (absolut verrückte) Ergebnis

Ich kompiliere es so (g ++ Version: Ubuntu 4.8.2-19ubuntu1):

g++ -O3 -march=native -std=c++11 test.cpp -o test

Hier sind die Ergebnisse auf meiner HaswellCore i7-4770K CPU bei 3,50 GHz, die test 1 ausführt (also 1 MB zufällige Daten):

  • vorzeichenlos 41959360000 0,401554 Sek. 26,113 GB/s
  • uint64_t 41959360000 0,759822 sec 13,8003 GB/s

Wie Sie sehen, ist der Durchsatz der uint64_t-Version nur die Hälfte der der unsigned-Version! Das Problem scheint zu sein, dass unterschiedliche Assemblys generiert werden, aber warum? Zuerst dachte ich an einen Compiler-Fehler und versuchte es mit clang++ (Ubuntu Clang Version 3.4-1ubuntu3):

clang++ -O3 -march=native -std=c++11 teest.cpp -o test

Ergebnis: test 1

  • vorzeichenlos 41959360000 0,398293 Sek. 26,3267 GB/s
  • uint64_t 41959360000 0,680954 sec 15,3986 GB/s

So ist es fast das gleiche Ergebnis und ist immer noch seltsam. Aber jetzt wird es sehr seltsam. Ich ersetze die Puffergröße, die von der Eingabe gelesen wurde, durch eine Konstante 1, also ändere ich:

uint64_t size = atol(argv[1]) << 20;

zu

uint64_t size = 1 << 20;

Somit kennt der Compiler jetzt die Puffergröße zur Kompilierzeit. Vielleicht kann es einige Optimierungen hinzufügen! Hier sind die Zahlen für g++:

  • vorzeichenlos 41959360000 0,509156 Sek. 20,5944 GB/s
  • uint64_t 41959360000 0,508673 Sek. 20,6139 GB/s

Jetzt sind beide Versionen gleich schnell. Die unsignedwurde noch langsamer! Es ist von 26 auf 20 GB/s gefallen, wodurch das Ersetzen einer Nichtkonstanten durch einen konstanten Wert zu einer Deoptimierung führte. Im Ernst, ich habe keine Ahnung, was hier los ist! Nun aber zu clang++ mit der neuen Version:

  • unsigned 41959360000 0,677009 sec 15,4884 GB/s
  • uint64_t 41959360000 0,676909 sec 15,4906 GB/s

Warten Sie, was? Nun fielen beide Versionen auf die langsam Zahl von 15 GB/s. Daher führt das Ersetzen einer Nichtkonstante durch einen konstanten Wert in beiden Fällen für Clang sogar zu langsamem Code!

Ich habe einen Kollegen mit einer Ivy Bridge CPU gebeten, meinen Benchmark zu kompilieren. Er hat ähnliche Ergebnisse erzielt, es scheint also nicht Haswell zu sein. Da zwei Compiler hier seltsame Ergebnisse liefern, scheint es sich auch nicht um einen Compilerfehler zu handeln. Wir haben hier keine AMD-CPU, daher konnten wir nur mit Intel testen.

Noch mehr Wahnsinn, bitte!

Nehmen Sie das erste Beispiel (das mit atol(argv[1])) und setzen Sie ein static vor die Variable, d. H .:

static uint64_t size=atol(argv[1])<<20;

Hier sind meine Ergebnisse in g ++:

  • vorzeichenlos 41959360000 0,396728 Sek. 26,4306 GB/s
  • uint64_t 41959360000 0,509484 Sek. 20,5811 GB/s

Ja, noch eine Alternative . Wir haben immer noch die schnellen 26 GB/s mit u32, aber wir haben es geschafft, u64 zumindest von den 13 GB/s auf die 20 GB/s-Version zu bringen! Auf dem PC meines Kollegen wurde die u64 -Version sogar schneller als die u32 -Version und lieferte das schnellste Ergebnis von allen. Leider funktioniert dies nur für g++, clang++ scheint sich nicht um static zu kümmern.

Meine Frage

Können Sie diese Ergebnisse erklären? Insbesondere:

  • Wie kann es einen solchen Unterschied zwischen u32 und u64 geben?
  • Wie kann das Ersetzen einer Nichtkonstanten durch eine Konstante der Puffergröße einen weniger optimalen Code auslösen?
  • Wie kann das Einfügen des Schlüsselworts static die Schleife u64 beschleunigen? Noch schneller als der Originalcode auf dem Computer meines Kollegen!

Ich weiß, dass Optimierung ein heikles Gebiet ist, aber ich hätte nie gedacht, dass so kleine Änderungen zu einem Unterschied von 100% in der Ausführungszeit führen können und dass kleine Faktoren wie eine konstante Puffergröße die Ergebnisse wieder vollständig vermischen können. Natürlich möchte ich immer die Version haben, die 26 GB/s zählen kann. Die einzige zuverlässige Möglichkeit, die mir in den Sinn kommt, besteht darin, die Assembly für diesen Fall zu kopieren, einzufügen und die Inline-Assembly zu verwenden. Nur so kann ich Compiler loswerden, die bei kleinen Änderungen verrückt zu werden scheinen. Was denkst du? Gibt es eine andere Möglichkeit, den Code mit der höchsten Leistung zuverlässig abzurufen?

Die Demontage

Hier ist die Demontage für die verschiedenen Ergebnisse:

26 GB/s-Version von g ++/u32/non-const bufsize:

0x400af8:
lea 0x1(%rdx),%eax
popcnt (%rbx,%rax,8),%r9
lea 0x2(%rdx),%edi
popcnt (%rbx,%rcx,8),%rax
lea 0x3(%rdx),%esi
add %r9,%rax
popcnt (%rbx,%rdi,8),%rcx
add $0x4,%edx
add %rcx,%rax
popcnt (%rbx,%rsi,8),%rcx
add %rcx,%rax
mov %edx,%ecx
add %rax,%r14
cmp %rbp,%rcx
jb 0x400af8

13 GB/s-Version von g ++/u64/non-const bufsize:

0x400c00:
popcnt 0x8(%rbx,%rdx,8),%rcx
popcnt (%rbx,%rdx,8),%rax
add %rcx,%rax
popcnt 0x10(%rbx,%rdx,8),%rcx
add %rcx,%rax
popcnt 0x18(%rbx,%rdx,8),%rcx
add $0x4,%rdx
add %rcx,%rax
add %rax,%r12
cmp %rbp,%rdx
jb 0x400c00

15 GB/s-Version von clang ++/u64/non-const bufsize:

0x400e50:
popcnt (%r15,%rcx,8),%rdx
add %rbx,%rdx
popcnt 0x8(%r15,%rcx,8),%rsi
add %rdx,%rsi
popcnt 0x10(%r15,%rcx,8),%rdx
add %rsi,%rdx
popcnt 0x18(%r15,%rcx,8),%rbx
add %rdx,%rbx
add $0x4,%rcx
cmp %rbp,%rcx
jb 0x400e50

20 GB/s-Version von g ++/u32 & u64/const bufsize:

0x400a68:
popcnt (%rbx,%rdx,1),%rax
popcnt 0x8(%rbx,%rdx,1),%rcx
add %rax,%rcx
popcnt 0x10(%rbx,%rdx,1),%rax
add %rax,%rcx
popcnt 0x18(%rbx,%rdx,1),%rsi
add $0x20,%rdx
add %rsi,%rcx
add %rcx,%rbp
cmp $0x100000,%rdx
jne 0x400a68

15 GB/s-Version von clang ++/u32 & u64/const bufsize:

0x400dd0:
popcnt (%r14,%rcx,8),%rdx
add %rbx,%rdx
popcnt 0x8(%r14,%rcx,8),%rsi
add %rdx,%rsi
popcnt 0x10(%r14,%rcx,8),%rdx
add %rsi,%rdx
popcnt 0x18(%r14,%rcx,8),%rbx
add %rdx,%rbx
add $0x4,%rcx
cmp $0x20000,%rcx
jb 0x400dd0

Interessanterweise ist die schnellste (26 GB/s) Version auch die längste! Es scheint die einzige Lösung zu sein, die lea verwendet. Einige Versionen verwenden jb, um zu springen, andere verwenden jne. Ansonsten scheinen alle Versionen vergleichbar zu sein. Ich verstehe nicht, woher eine 100% ige Leistungslücke stammen könnte, bin aber nicht so geschickt darin, Assembly zu entschlüsseln. Die langsamste (13 GB/s) Version sieht sogar sehr kurz und gut aus. Kann mir jemand das erklären?

Gewonnene Erkenntnisse

Egal wie die Antwort auf diese Frage sein wird; Ich habe gelernt, dass in wirklich heißen Schleifen jedes Detail eine Rolle spielen kann, sogar Details, die keine Assoziation mit dem heißen Code zu haben scheinen . Ich habe nie darüber nachgedacht, welchen Typ ich für eine Schleifenvariable verwenden soll, aber wie Sie sehen, kann eine so geringfügige Änderung einen Unterschied von 100% bewirken! Sogar der Speichertyp eines Puffers kann einen großen Unterschied machen, wie wir beim Einfügen des Schlüsselworts static vor der Variablen size gesehen haben! In Zukunft werde ich immer verschiedene Alternativen auf verschiedenen Compilern testen, wenn ich wirklich enge und heiße Schleifen schreibe, die für die Systemleistung entscheidend sind.

Das Interessante ist auch, dass der Leistungsunterschied immer noch so hoch ist, obwohl ich die Schleife bereits vier Mal abgerollt habe. Selbst wenn Sie sich abrollen, können Sie immer noch von großen Leistungsabweichungen getroffen werden. Ziemlich interessant.

1340
gexicide

Culprit: False Data Dependency (und der Compiler ist sich dessen nicht einmal bewusst)

Auf Sandy/Ivy Bridge- und Haswell-Prozessoren lautet die Anweisung:

popcnt  src, dest

scheint eine falsche Abhängigkeit vom Zielregister dest zu haben. Obwohl der Befehl nur darauf schreibt, wartet der Befehl, bis dest bereit ist, bevor er ausgeführt wird.

Diese Abhängigkeit hält nicht nur die 4 popcnts einer einzelnen Schleifeniteration hoch. Es kann über Schleifeniterationen übertragen werden, was es dem Prozessor unmöglich macht, verschiedene Schleifeniterationen zu parallelisieren.

Die unsigned vs. uint64_t und andere Verbesserungen wirken sich nicht direkt auf das Problem aus. Sie beeinflussen jedoch den Registerzuweiser, der die Register den Variablen zuordnet.

In Ihrem Fall sind die Geschwindigkeiten ein direktes Ergebnis dessen, was an der (falschen) Abhängigkeitskette hängt, je nachdem, was der Registerzuteiler beschlossen hat.

  • 13 GB/s hat eine Kette: popcnt-add-popcnt-popcnt → nächste Iteration
  • 15 GB/s hat eine Kette: popcnt-add-popcnt-add → nächste Iteration
  • 20 GB/s hat eine Kette: popcnt-popcnt → nächste Iteration
  • 26 GB/s hat eine Kette: popcnt-popcnt → nächste Iteration

Der Unterschied zwischen 20 GB/s und 26 GB/s scheint ein geringfügiges Artefakt der indirekten Adressierung zu sein. In jedem Fall stößt der Prozessor bei Erreichen dieser Geschwindigkeit auf andere Engpässe.


Um dies zu testen, habe ich Inline-Assembly verwendet, um den Compiler zu umgehen und genau die Assembly zu erhalten, die ich haben möchte. Ich habe auch die Variable count aufgeteilt, um alle anderen Abhängigkeiten zu beseitigen, die mit den Benchmarks in Konflikt geraten könnten.

Hier sind die Ergebnisse:

Sandy Bridge Xeon bei 3,5 GHz: (Den vollständigen Testcode finden Sie unten)

  • GCC 4.6.3: g++ popcnt.cpp -std=c++0x -O3 -save-temps -march=native
  • Ubuntu 12

Verschiedene Register: 18.6195 GB/s

.L4:
    movq    (%rbx,%rax,8), %r8
    movq    8(%rbx,%rax,8), %r9
    movq    16(%rbx,%rax,8), %r10
    movq    24(%rbx,%rax,8), %r11
    addq    $4, %rax

    popcnt %r8, %r8
    add    %r8, %rdx
    popcnt %r9, %r9
    add    %r9, %rcx
    popcnt %r10, %r10
    add    %r10, %rdi
    popcnt %r11, %r11
    add    %r11, %rsi

    cmpq    $131072, %rax
    jne .L4

Gleiches Register: 8,49272 GB/s

.L9:
    movq    (%rbx,%rdx,8), %r9
    movq    8(%rbx,%rdx,8), %r10
    movq    16(%rbx,%rdx,8), %r11
    movq    24(%rbx,%rdx,8), %rbp
    addq    $4, %rdx

    # This time reuse "rax" for all the popcnts.
    popcnt %r9, %rax
    add    %rax, %rcx
    popcnt %r10, %rax
    add    %rax, %rsi
    popcnt %r11, %rax
    add    %rax, %r8
    popcnt %rbp, %rax
    add    %rax, %rdi

    cmpq    $131072, %rdx
    jne .L9

Gleiches Register mit unterbrochener Kette: 17.8869 GB/s

.L14:
    movq    (%rbx,%rdx,8), %r9
    movq    8(%rbx,%rdx,8), %r10
    movq    16(%rbx,%rdx,8), %r11
    movq    24(%rbx,%rdx,8), %rbp
    addq    $4, %rdx

    # Reuse "rax" for all the popcnts.
    xor    %rax, %rax    # Break the cross-iteration dependency by zeroing "rax".
    popcnt %r9, %rax
    add    %rax, %rcx
    popcnt %r10, %rax
    add    %rax, %rsi
    popcnt %r11, %rax
    add    %rax, %r8
    popcnt %rbp, %rax
    add    %rax, %rdi

    cmpq    $131072, %rdx
    jne .L14

Was ist also mit dem Compiler schief gelaufen?

Es scheint, dass weder GCC noch Visual Studio wissen, dass popcnt eine solche falsche Abhängigkeit aufweist. Trotzdem sind diese falschen Abhängigkeiten keine Seltenheit. Es ist nur eine Frage, ob der Compiler davon Kenntnis hat.

popcnt ist nicht gerade die am häufigsten verwendete Anweisung. Es ist also keine große Überraschung, dass ein großer Compiler so etwas vermissen könnte. Es scheint auch nirgendwo eine Dokumentation zu geben, die dieses Problem erwähnt. Wenn Intel es nicht preisgibt, weiß es niemand, bis jemand zufällig darauf stößt.

( Update: Ab Version 4.9.2 , GCC ist sich dieser falschen Abhängigkeit bewusst und generiert Code, um sie zu kompensieren, wenn Optimierungen sind aktiviert. Wichtige Compiler anderer Hersteller, darunter Clang, MSVC und sogar Intels eigener ICC, kennen dieses mikroarchitektonische Problem noch nicht und geben keinen Code aus, der es kompensiert.)

Warum hat die CPU eine so falsche Abhängigkeit?

Wir können nur spekulieren, aber es ist wahrscheinlich, dass Intel bei vielen Zwei-Operanden-Anweisungen die gleiche Vorgehensweise anwendet. Allgemeine Anweisungen wie add, sub verwenden zwei Operanden, die beide Eingänge sind. Daher hat Intel popcnt wahrscheinlich in dieselbe Kategorie eingestuft, um das Prozessordesign einfach zu halten.

AMD-Prozessoren scheinen diese falsche Abhängigkeit nicht zu haben.


Der vollständige Testcode ist unten als Referenz:

#include <iostream>
#include <chrono>
#include <x86intrin.h>

int main(int argc, char* argv[]) {

   using namespace std;
   uint64_t size=1<<20;

   uint64_t* buffer = new uint64_t[size/8];
   char* charbuffer=reinterpret_cast<char*>(buffer);
   for (unsigned i=0;i<size;++i) charbuffer[i]=Rand()%256;

   uint64_t count,duration;
   chrono::time_point<chrono::system_clock> startP,endP;
   {
      uint64_t c0 = 0;
      uint64_t c1 = 0;
      uint64_t c2 = 0;
      uint64_t c3 = 0;
      startP = chrono::system_clock::now();
      for( unsigned k = 0; k < 10000; k++){
         for (uint64_t i=0;i<size/8;i+=4) {
            uint64_t r0 = buffer[i + 0];
            uint64_t r1 = buffer[i + 1];
            uint64_t r2 = buffer[i + 2];
            uint64_t r3 = buffer[i + 3];
            __asm__(
                "popcnt %4, %4  \n\t"
                "add %4, %0     \n\t"
                "popcnt %5, %5  \n\t"
                "add %5, %1     \n\t"
                "popcnt %6, %6  \n\t"
                "add %6, %2     \n\t"
                "popcnt %7, %7  \n\t"
                "add %7, %3     \n\t"
                : "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
                : "r"  (r0), "r"  (r1), "r"  (r2), "r"  (r3)
            );
         }
      }
      count = c0 + c1 + c2 + c3;
      endP = chrono::system_clock::now();
      duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "No Chain\t" << count << '\t' << (duration/1.0E9) << " sec \t"
            << (10000.0*size)/(duration) << " GB/s" << endl;
   }
   {
      uint64_t c0 = 0;
      uint64_t c1 = 0;
      uint64_t c2 = 0;
      uint64_t c3 = 0;
      startP = chrono::system_clock::now();
      for( unsigned k = 0; k < 10000; k++){
         for (uint64_t i=0;i<size/8;i+=4) {
            uint64_t r0 = buffer[i + 0];
            uint64_t r1 = buffer[i + 1];
            uint64_t r2 = buffer[i + 2];
            uint64_t r3 = buffer[i + 3];
            __asm__(
                "popcnt %4, %%rax   \n\t"
                "add %%rax, %0      \n\t"
                "popcnt %5, %%rax   \n\t"
                "add %%rax, %1      \n\t"
                "popcnt %6, %%rax   \n\t"
                "add %%rax, %2      \n\t"
                "popcnt %7, %%rax   \n\t"
                "add %%rax, %3      \n\t"
                : "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
                : "r"  (r0), "r"  (r1), "r"  (r2), "r"  (r3)
                : "rax"
            );
         }
      }
      count = c0 + c1 + c2 + c3;
      endP = chrono::system_clock::now();
      duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "Chain 4   \t"  << count << '\t' << (duration/1.0E9) << " sec \t"
            << (10000.0*size)/(duration) << " GB/s" << endl;
   }
   {
      uint64_t c0 = 0;
      uint64_t c1 = 0;
      uint64_t c2 = 0;
      uint64_t c3 = 0;
      startP = chrono::system_clock::now();
      for( unsigned k = 0; k < 10000; k++){
         for (uint64_t i=0;i<size/8;i+=4) {
            uint64_t r0 = buffer[i + 0];
            uint64_t r1 = buffer[i + 1];
            uint64_t r2 = buffer[i + 2];
            uint64_t r3 = buffer[i + 3];
            __asm__(
                "xor %%rax, %%rax   \n\t"   // <--- Break the chain.
                "popcnt %4, %%rax   \n\t"
                "add %%rax, %0      \n\t"
                "popcnt %5, %%rax   \n\t"
                "add %%rax, %1      \n\t"
                "popcnt %6, %%rax   \n\t"
                "add %%rax, %2      \n\t"
                "popcnt %7, %%rax   \n\t"
                "add %%rax, %3      \n\t"
                : "+r" (c0), "+r" (c1), "+r" (c2), "+r" (c3)
                : "r"  (r0), "r"  (r1), "r"  (r2), "r"  (r3)
                : "rax"
            );
         }
      }
      count = c0 + c1 + c2 + c3;
      endP = chrono::system_clock::now();
      duration=chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "Broken Chain\t"  << count << '\t' << (duration/1.0E9) << " sec \t"
            << (10000.0*size)/(duration) << " GB/s" << endl;
   }

   free(charbuffer);
}

Eine ebenso interessante Benchmark finden Sie hier: http://Pastebin.com/kbzgL8si
Dieser Benchmark variiert die Anzahl der popcnt, die sich in der (falschen) Abhängigkeitskette befinden.

False Chain 0:  41959360000 0.57748 sec     18.1578 GB/s
False Chain 1:  41959360000 0.585398 sec    17.9122 GB/s
False Chain 2:  41959360000 0.645483 sec    16.2448 GB/s
False Chain 3:  41959360000 0.929718 sec    11.2784 GB/s
False Chain 4:  41959360000 1.23572 sec     8.48557 GB/s
1487
Mysticial

Ich habe ein äquivalentes C-Programm zum Experimentieren programmiert und kann dieses seltsame Verhalten bestätigen. Darüber hinaus ist gcc der Ansicht, dass die 64-Bit-Ganzzahl (die wahrscheinlich ohnehin ein size_t sein sollte ...) besser ist, da die Verwendung von uint_fast32_t dazu führt, dass gcc eine 64-Bit-Ganzzahl verwendet .

Ich habe ein bisschen mit der Versammlung rumgespielt:
Nehmen Sie einfach die 32-Bit-Version und ersetzen Sie alle 32-Bit-Befehle/-Register durch die 64-Bit-Version in der inneren Popcount-Schleife des Programms. Beobachtung: Der Code ist genauso schnell wie die 32-Bit-Version!

Dies ist offensichtlich ein Hack, da die Größe der Variablen nicht wirklich 64-Bit ist, da andere Teile des Programms immer noch die 32-Bit-Version verwenden, aber solange die innere Popcount-Schleife die Leistung dominiert, Das ist ein guter Anfang.

Ich habe dann den Code für die innere Schleife aus der 32-Bit-Version des Programms kopiert, ihn auf 64-Bit gehackt und mit den Registern getüftelt, um ihn als Ersatz für die innere Schleife der 64-Bit-Version zu verwenden. Dieser Code läuft auch so schnell wie die 32-Bit-Version.

Mein Fazit ist, dass dies eine schlechte Befehlsplanung durch den Compiler ist und nicht der tatsächliche Geschwindigkeits-/Latenzvorteil von 32-Bit-Befehlen.

(Vorbehalt: Ich habe Assembly gehackt, hätte etwas kaputt machen können, ohne es zu merken. Ich glaube nicht.)

50
EOF

Dies ist keine Antwort, aber es ist schwer zu lesen, wenn ich die Ergebnisse kommentiere.

Ich erhalte diese Ergebnisse mit einem Mac Pro ( Westmere 6-Cores Xeon 3.33 GHz). Ich habe es mit clang -O3 -msse4 -lstdc++ a.cpp -o a kompiliert (-O2 bekomme das selbe Ergebnis).

klingeln mit uint64_t size=atol(argv[1])<<20;

unsigned    41950110000 0.811198 sec    12.9263 GB/s
uint64_t    41950110000 0.622884 sec    16.8342 GB/s

klingelte mit uint64_t size=1<<20;

unsigned    41950110000 0.623406 sec    16.8201 GB/s
uint64_t    41950110000 0.623685 sec    16.8126 GB/s

Ich habe auch versucht:

  1. Kehren Sie die Testreihenfolge um, das Ergebnis ist das gleiche, sodass der Cache-Faktor ausgeschlossen wird.
  2. Habe die for Anweisung umgekehrt: for (uint64_t i=size/8;i>0;i-=4). Dies ergibt dasselbe Ergebnis und beweist, dass die Kompilierung intelligent genug ist, um die Größe nicht bei jeder Iteration durch 8 zu teilen (wie erwartet).

Hier ist meine wilde Vermutung:

Der Geschwindigkeitsfaktor besteht aus drei Teilen:

  • code-Cache: uint64_t Version hat eine größere Codegröße, dies hat jedoch keine Auswirkungen auf meine Xeon-CPU. Dadurch wird die 64-Bit-Version langsamer.

  • Anweisungen verwendet. Beachten Sie nicht nur die Anzahl der Schleifen, sondern auch den Zugriff auf den Puffer mit einem 32-Bit- und einem 64-Bit-Index für die beiden Versionen. Wenn Sie auf einen Zeiger mit einem 64-Bit-Versatz zugreifen, müssen Sie ein dediziertes 64-Bit-Register und eine dedizierte Adressierung anfordern, während Sie für einen 32-Bit-Versatz den Direktzugriff verwenden können. Dies kann die 32-Bit-Version schneller machen.

  • Anweisungen werden nur bei der 64-Bit-Kompilierung (dh Prefetch) ausgegeben. Dies macht 64-Bit schneller.

Die drei Faktoren stimmen zusammen mit den beobachteten scheinbar widersprüchlichen Ergebnissen überein.

Ich habe dies mit Visual Studio 2013 Express versucht, wobei ein Zeiger anstelle eines Index verwendet wurde, was den Prozess etwas beschleunigte. Ich vermute, das liegt daran, dass die Adressierung Offset + Register ist, anstatt Offset + Register + (Register << 3). C++ Code.

   uint64_t* bfrend = buffer+(size/8);
   uint64_t* bfrptr;

// ...

   {
      startP = chrono::system_clock::now();
      count = 0;
      for (unsigned k = 0; k < 10000; k++){
         // Tight unrolled loop with uint64_t
         for (bfrptr = buffer; bfrptr < bfrend;){
            count += __popcnt64(*bfrptr++);
            count += __popcnt64(*bfrptr++);
            count += __popcnt64(*bfrptr++);
            count += __popcnt64(*bfrptr++);
         }
      }
      endP = chrono::system_clock::now();
      duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
      cout << "uint64_t\t"  << count << '\t' << (duration/1.0E9) << " sec \t"
           << (10000.0*size)/(duration) << " GB/s" << endl;
   }

Assemblycode: r10 = bfrptr, r15 = bfrend, rsi = count, rdi = buffer, r13 = k:

[email protected]:
        mov     r10, rdi
        cmp     rdi, r15
        jae     SHORT [email protected]
        npad    4
[email protected]:
        mov     rax, QWORD PTR [r10+24]
        mov     rcx, QWORD PTR [r10+16]
        mov     r8, QWORD PTR [r10+8]
        mov     r9, QWORD PTR [r10]
        popcnt  rdx, rax
        popcnt  rax, rcx
        add     rdx, rax
        popcnt  rax, r8
        add     r10, 32
        add     rdx, rax
        popcnt  rax, r9
        add     rsi, rax
        add     rsi, rdx
        cmp     r10, r15
        jb      SHORT [email protected]in
[email protected]:
        dec     r13
        jne     SHORT [email protected]
10
rcgldr

Haben Sie versucht, -funroll-loops -fprefetch-loop-arrays an GCC zu übergeben?

Mit diesen zusätzlichen Optimierungen erhalte ich die folgenden Ergebnisse:

[1829] /tmp/so_25078285 $ cat /proc/cpuinfo |grep CPU|head -n1
model name      : Intel(R) Core(TM) i3-3225 CPU @ 3.30GHz
[1829] /tmp/so_25078285 $ g++ --version|head -n1
g++ (Ubuntu/Linaro 4.7.3-1ubuntu1) 4.7.3

[1829] /tmp/so_25078285 $ g++ -O3 -march=native -std=c++11 test.cpp -o test_o3
[1829] /tmp/so_25078285 $ g++ -O3 -march=native -funroll-loops -fprefetch-loop-arrays -std=c++11     test.cpp -o test_o3_unroll_loops__and__prefetch_loop_arrays

[1829] /tmp/so_25078285 $ ./test_o3 1
unsigned        41959360000     0.595 sec       17.6231 GB/s
uint64_t        41959360000     0.898626 sec    11.6687 GB/s

[1829] /tmp/so_25078285 $ ./test_o3_unroll_loops__and__prefetch_loop_arrays 1
unsigned        41959360000     0.618222 sec    16.9612 GB/s
uint64_t        41959360000     0.407304 sec    25.7443 GB/s
9
Dangelov

Haben Sie versucht, die Reduktionsstufe außerhalb der Schleife zu bewegen? Im Moment haben Sie eine Datenabhängigkeit, die wirklich nicht benötigt wird.

Versuchen:

  uint64_t subset_counts[4] = {};
  for( unsigned k = 0; k < 10000; k++){
     // Tight unrolled loop with unsigned
     unsigned i=0;
     while (i < size/8) {
        subset_counts[0] += _mm_popcnt_u64(buffer[i]);
        subset_counts[1] += _mm_popcnt_u64(buffer[i+1]);
        subset_counts[2] += _mm_popcnt_u64(buffer[i+2]);
        subset_counts[3] += _mm_popcnt_u64(buffer[i+3]);
        i += 4;
     }
  }
  count = subset_counts[0] + subset_counts[1] + subset_counts[2] + subset_counts[3];

Es gibt auch ein seltsames Aliasing, bei dem ich nicht sicher bin, ob es den strengen Aliasing-Regeln entspricht.

7
Ben Voigt

TL; DR: Verwenden Sie stattdessen __builtin intrinsics.

Ich konnte gcc 4.8.4 (und sogar 4.7.3 auf gcc.godbolt.org) dazu bringen, optimalen Code dafür zu generieren, indem ich __builtin_popcountll verwende, der dieselbe Assembly-Anweisung verwendet, aber nicht hat dieser falsche Abhängigkeitsfehler.

Ich bin mir meines Benchmarking-Codes nicht hundertprozentig sicher, aber die Ausgabe von objdump scheint meine Ansichten zu teilen. Ich benutze einige andere Tricks (++i vs i++), um die Compiler-Unroll-Schleife für mich ohne movl -Anweisung zu machen (seltsames Verhalten, muss ich sagen).

Ergebnisse:

Count: 20318230000  Elapsed: 0.411156 seconds   Speed: 25.503118 GB/s

Benchmarking-Code:

#include <stdint.h>
#include <stddef.h>
#include <time.h>
#include <stdio.h>
#include <stdlib.h>

uint64_t builtin_popcnt(const uint64_t* buf, size_t len){
  uint64_t cnt = 0;
  for(size_t i = 0; i < len; ++i){
    cnt += __builtin_popcountll(buf[i]);
  }
  return cnt;
}

int main(int argc, char** argv){
  if(argc != 2){
    printf("Usage: %s <buffer size in MB>\n", argv[0]);
    return -1;
  }
  uint64_t size = atol(argv[1]) << 20;
  uint64_t* buffer = (uint64_t*)malloc((size/8)*sizeof(*buffer));

  // Spoil copy-on-write memory allocation on *nix
  for (size_t i = 0; i < (size / 8); i++) {
    buffer[i] = random();
  }
  uint64_t count = 0;
  clock_t tic = clock();
  for(size_t i = 0; i < 10000; ++i){
    count += builtin_popcnt(buffer, size/8);
  }
  clock_t toc = clock();
  printf("Count: %lu\tElapsed: %f seconds\tSpeed: %f GB/s\n", count, (double)(toc - tic) / CLOCKS_PER_SEC, ((10000.0*size)/(((double)(toc - tic)*1e+9) / CLOCKS_PER_SEC)));
  return 0;
}

Kompilieroptionen:

gcc --std=gnu99 -mpopcnt -O3 -funroll-loops -march=native bench.c -o bench

GCC-Version:

gcc (Ubuntu 4.8.4-2ubuntu1~14.04.1) 4.8.4

Linux-Kernel-Version:

3.19.0-58-generic

CPU-Informationen:

processor   : 0
vendor_id   : GenuineIntel
cpu family  : 6
model       : 70
model name  : Intel(R) Core(TM) i7-4870HQ CPU @ 2.50 GHz
stepping    : 1
microcode   : 0xf
cpu MHz     : 2494.226
cache size  : 6144 KB
physical id : 0
siblings    : 1
core id     : 0
cpu cores   : 1
apicid      : 0
initial apicid  : 0
fpu     : yes
fpu_exception   : yes
cpuid level : 13
wp      : yes
flags       : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx rdtscp lm constant_tsc nopl xtopology nonstop_tsc eagerfpu pni pclmulqdq ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm arat pln pts dtherm fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 invpcid xsaveopt
bugs        :
bogomips    : 4988.45
clflush size    : 64
cache_alignment : 64
address sizes   : 36 bits physical, 48 bits virtual
power management:
5
assp1r1n3