Ich suchte nach dem schnellsten Weg zu popcount
großen Datenfeldern. Ich habe einen sehr seltsamen Effekt festgestellt : Durch Ändern der Schleifenvariablen von, unsigned
um uint64_t
die Leistung auf meinem PC um 50% zu senken.
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, wobei die Größe x
Megabyte x
beträgt und von der Befehlszeile gelesen wird. Anschließend durchlaufen wir den Puffer und verwenden eine nicht gerollte Version des x86- popcount
Intrinsic, um die Popcount durchzuführen. Um ein genaueres Ergebnis zu erhalten, führen wir den Popcount 10.000 Mal durch. Wir messen die Zeiten für den Popcount. Im Großbuchstaben ist die Variable der inneren Schleife unsigned
, im Kleinbuchstaben ist die Variable der inneren Schleife uint64_t
. Ich dachte, dass dies keinen Unterschied machen sollte, aber das Gegenteil ist der Fall.
Die (absolut verrückten) Ergebnisse
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 Haswell Core i7-4770K- CPU bei 3,50 GHz, die ausgeführt wird test 1
(also 1 MB zufällige Daten):
- vorzeichenlos 41959360000 0,401554 Sek. 26,113 GB / s
- uint64_t 41959360000 0,759822 Sek. 13,8003 GB / s
Wie Sie sehen, ist der Durchsatz der uint64_t
Version nur halb so hoch wie der der unsigned
Version! Das Problem scheint zu sein, dass unterschiedliche Baugruppen generiert werden, aber warum? Zuerst dachte ich an einen Compiler-Fehler, also versuchte ich es 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 Sek. 15,3986 GB / s
Es ist also fast das gleiche Ergebnis und immer noch seltsam. Aber jetzt wird es super 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 Kompilierungszeit. 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. Das wurde unsigned
jedoch noch langsamer ! Es fiel von 26
bis ab 20 GB/s
, wodurch eine Nichtkonstante durch einen konstanten Wert ersetzt wurde, was zu einer Deoptimierung führte . Im Ernst, ich habe keine Ahnung, was hier los ist! Nun aber zur clang++
neuen Version:
- vorzeichenlos 41959360000 0,677009 Sek. 15,4484 GB / s
- uint64_t 41959360000 0,676909 Sek. 15,4906 GB / s
Warte was? Jetzt fielen beide Versionen auf die langsame Zahl von 15 GB / s. Das Ersetzen einer Nichtkonstante durch einen konstanten Wert führt in beiden Fällen sogar zu langsamem Code für Clang!
Ich habe einen Kollegen mit einer Ivy Bridge- CPU gebeten , meinen Benchmark zu erstellen. Er hat ähnliche Ergebnisse erzielt, daher scheint es nicht Haswell zu sein. Da zwei Compiler hier seltsame Ergebnisse liefern, scheint es sich auch nicht um einen Compiler-Fehler 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, dh:
static uint64_t size=atol(argv[1])<<20;
Hier sind meine Ergebnisse in g ++:
- ohne Vorzeichen 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
mindestens von 13 GB / s auf die 20 GB / s-Version zu kommen! Auf dem PC meines Kollegen wurde die u64
Version sogar noch schneller als die u32
Version und lieferte das schnellste Ergebnis von allen. Leider funktioniert dies nur für g++
, clang++
scheint sich nicht darum zu kümmern static
.
Meine Frage
Können Sie diese Ergebnisse erklären? Insbesondere:
- Wie kann es einen solchen Unterschied zwischen
u32
und gebenu64
? - Wie kann das Ersetzen einer nicht konstanten durch eine konstante Puffergröße weniger optimalen Code auslösen ?
- Wie kann das Einfügen des
static
Schlüsselworts dieu64
Schleife beschleunigen? Noch schneller als der Originalcode auf dem Computer meines Kollegen!
Ich weiß, dass die Optimierung ein heikles Gebiet ist, aber ich hätte nie gedacht, dass so kleine Änderungen zu einem 100% igen Unterschied in der Ausführungszeit führen können und dass kleine Faktoren wie eine konstante Puffergröße die Ergebnisse wieder vollständig mischen können. Natürlich möchte ich immer die Version haben, die 26 GB / s popcount kann. Der einzige zuverlässige Weg, den ich mir vorstellen kann, ist das Kopieren, Einfügen der Baugruppe für diesen Fall und die Verwendung der Inline-Baugruppe. Nur so kann ich Compiler loswerden, die bei kleinen Änderungen verrückt zu werden scheinen. Was denken Sie? 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 Version (26 GB / s) auch die längste! Es scheint die einzige Lösung zu sein, die verwendet wird lea
. Einige Versionen verwenden, um jb
zu springen, andere verwenden jne
. Abgesehen davon scheinen alle Versionen vergleichbar zu sein. Ich sehe nicht, woher eine 100% ige Leistungslücke stammen könnte, aber ich bin nicht so geschickt darin, Baugruppen zu entschlüsseln. Die langsamste Version (13 GB / s) sieht sogar sehr kurz und gut aus. Kann jemand das erklären?
Gewonnene Erkenntnisse
Egal wie die Antwort auf diese Frage lautet; Ich habe gelernt, dass in wirklich heißen Schleifen jedes Detail eine Rolle spielen kann, auch Details, die keine Verbindung zum heißen Code zu haben scheinen . Ich habe noch nie darüber nachgedacht, welchen Typ ich für eine Schleifenvariable verwenden soll, aber wie Sie sehen, kann eine so geringfügige Änderung einen 100% igen Unterschied bewirken ! Sogar der Speichertyp eines Puffers kann einen großen Unterschied machen, wie wir beim Einfügen des static
Schlüsselworts vor der Größenvariablen 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 viermal abgewickelt habe. Selbst wenn Sie sich abrollen, können Sie dennoch von großen Leistungsabweichungen betroffen sein. Ziemlich interessant.