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.
#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.
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):
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
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++
:
Jetzt sind beide Versionen gleich schnell. Die unsigned
wurde 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:
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.
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 ++:
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.
Können Sie diese Ergebnisse erklären? Insbesondere:
u32
und u64
geben?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?
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?
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.
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 popcnt
s 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.
popcnt
-add
-popcnt
-popcnt
→ nächste Iterationpopcnt
-add
-popcnt
-add
→ nächste Iterationpopcnt
-popcnt
→ nächste Iterationpopcnt
-popcnt
→ nächste IterationDer 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)
g++ popcnt.cpp -std=c++0x -O3 -save-temps -march=native
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
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.)
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).
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
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:
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]
[email protected]:
dec r13
jne SHORT [email protected]
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
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.
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: