Le remplacement d'un compteur de boucle de 32 bits par un compteur de boucle de 64 bits introduit des déviations de performance folles
je cherchais le moyen le plus rapide de popcount
grands tableaux de données. J'ai rencontré un très bizarre effet: Changer la variable de boucle de unsigned
à uint64_t
fait la baisse de performance de 50% sur mon PC.
La Référence
#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 << "unsignedt" << 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_tt" << count << 't' << (duration/1.0E9) << " sec t"
<< (10000.0*size)/(duration) << " GB/s" << endl;
}
free(charbuffer);
}
comme vous le voyez, nous créons un tampon de données aléatoires, avec la taille étant x
mégaoctets où x
est lu à partir de la ligne de commande. Ensuite, nous itérons sur le tampon et utilisons une version déroulante du x86 popcount
intrinsèque pour effectuer le popcount. Pour obtenir un résultat plus précis, nous faisons le popcount 10 000 fois. Nous mesurons les temps pour le popcount. Dans le cas supérieur, la variable de boucle interne est unsigned
, dans le cas inférieur, la variable de boucle interne est uint64_t
. J'ai pensé que cela ne devrait faire aucune différence, mais le contraire est le cas.
(de la folie) résultats
je le compile comme ceci (version g++: Ubuntu 4.8.2-19ubuntu1):
g++ -O3 -march=native -std=c++11 test.cpp -o test
Voici les résultats de mon Haswell Core i7-4770K CPU @ 3.50 GHz, en cours d'exécution test 1
(so 1 MB random data):
- non signé 41959360000 0.401554 sec 26.113 GB /s
- uint64_t 41959360000 0.759822 sec 13.8003 GB /s
comme vous le voyez, le débit de la version uint64_t
est seulement la moitié celui de la version unsigned
! Le problème semble être que différents assemblages sont générés, mais pourquoi? Tout d'abord, j'ai pensé à un bug de compilateur, donc j'ai essayé clang++
(Ubuntu Clang version 3.4-1ubuntu3):
clang++ -O3 -march=native -std=c++11 teest.cpp -o test
résultat: test 1
- non signé 41959360000 0.398293 sec 26.3267 GB /s
- uint64_t 41959360000 0.680954 sec 15.3986 GB/s
ainsi, il est presque le même résultat et est encore étrange. mais maintenant ça devient super étrange. je remplace la taille du tampon qui a été lu à partir de l'entrée par une constante 1
, donc je change:
uint64_t size = atol(argv[1]) << 20;
à
uint64_t size = 1 << 20;
ainsi, le compilateur connaît maintenant la taille du tampon au moment de la compilation. Peut-être il peut ajouter quelques optimisations! Voici les nombres pour g++
:
- non signé 41959360000 0.509156 sec 20.5944 GB /s
- uint64_t 41959360000 0.508673 sec 20.6139 GB/s
maintenant, les deux versions sont tout aussi rapide. Cependant, le unsigned
est devenu encore plus lent ! Il est passé de 26
à 20 GB/s
, remplaçant ainsi une non-constante par une valeur constante conduisant à une désoptimisation . Sérieusement, je n'ai aucune idée de ce qui se passe ici! Mais maintenant à clang++
avec la nouvelle version:
- non signé 41959360000 0.677009 sec 15.4884 GB /s
- un uint64_t 41959360000 0.676909 sec 15.4906 GB /s
attendez, quoi? maintenant, les deux versions sont tombées au lent nombre de 15 Go / s. Ainsi, remplacer une non-constante par une valeur constante conduit même au Code lent dans les deux cas pour Clang!
j'ai demandé à un collègue avec un CPU Ivy Bridge de compiler mon benchmark. Il a obtenu résultats similaires, il ne semble donc pas être Haswell. Parce que deux compilateurs produisent des résultats étranges ici, il ne semble pas non plus être un bogue de compilateur. Nous n'avons pas de processeur AMD ici, donc nous ne pouvons tester qu'avec des informations.
plus de folie, s'il vous plaît!
prendre le premier exemple (celui avec atol(argv[1])
) et mettre un static
avant la variable, i.e.:
static uint64_t size=atol(argv[1])<<20;
Voici mes résultats en g++:
- non signé 41959360000 0.396728 sec 26.4306 GB /s
- uint64_t 41959360000 0.509484 sec 20.5811 GB/s
Youpi, encore une autre alternative . Nous avons encore les 26 GO / s rapides avec u32
, mais nous avons réussi à obtenir u64
au moins de la 13 Go/s à la version 20 Go/s! sur le PC de mon collègue, le La version u64
est devenue encore plus rapide que la version u32
, donnant le résultat le plus rapide de tous. malheureusement, cela ne fonctionne que pour g++
, clang++
ne semble pas se soucier de static
.
ma question
Pouvez-vous expliquer ces résultats? En particulier:
- Comment peut-il y avoir une telle différence entre
u32
etu64
? - Comment peut remplacement d'une non-constante par un tampon de taille constante déclencheur Code moins optimal ?
- comment l'insertion du mot-clé
static
peut-elle accélérer la boucleu64
? Encore plus rapide que le code original sur l'ordinateur de mon collègue!
je sais que l'optimisation est un territoire délicat, cependant, je n'ai jamais pensé que de tels petits changements peuvent conduire à un 100% de différence dans l'exécution le temps et que de petits facteurs comme une taille de tampon constante peuvent à nouveau mélanger totalement les résultats. Bien sûr, je veux toujours avoir la version capable de popcount 26 GB/s. La seule façon fiable dont je peux penser Est copier coller l'assemblage pour ce cas et utiliser l'assemblage en ligne. C'est la seule façon je peux me débarrasser de compilateurs qui semblent s'énerver sur les petites modifications. Qu'en pensez-vous? Y a-t-il une autre façon d'obtenir le code de façon fiable avec la plus grande performance?
Le Démontage
voici le démontage pour les différents résultats:
26 GB/s version from 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 "151960920"x4,%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 from 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 "151970920"x4,%rdx
add %rcx,%rax
add %rax,%r12
cmp %rbp,%rdx
jb 0x400c00
15 GB/s version from 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 "151980920"x4,%rcx
cmp %rbp,%rcx
jb 0x400e50
20 GB/s version de 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 "151990920"x20,%rdx
add %rsi,%rcx
add %rcx,%rbp
cmp "151990920"x100000,%rdx
jne 0x400a68
15 GB/s version from 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 "1519100920"x4,%rcx
cmp "1519100920"x20000,%rcx
jb 0x400dd0
fait intéressant, la version la plus rapide (26 GO/s) est aussi la plus longue! Il semble être la seule solution qui utilise lea
. Certaines versions utilisent jb
pour sauter, d'autres utilisent jne
. Mais en dehors de cela, toutes les versions semblent comparables. Je n'ai pas voyez d'où pourrait venir un écart de performance de 100%, mais je ne suis pas trop habile à déchiffrer l'assemblage. La version la plus lente (13 Go/s) semble même très courte et bonne. Quelqu'un peut-il expliquer cela?
leçons apprises
quelle que soit la réponse à cette question, j'ai appris que dans les boucles vraiment chaudes chaque le détail peut avoir de l'importance, même les détails qui ne semblent avoir aucune association avec le code chaud . Je n'ai jamais pensé à quel type utiliser pour une variable de boucle, mais comme vous voyez un tel changement mineur peut faire une 100% différence! Même le type de stockage d'un tampon peut faire une énorme différence, comme nous l'avons vu avec l'insertion du mot-clé static
devant la variable de taille! À l'avenir, je testerai toujours Diverses alternatives sur divers compilateurs lors de l'écriture vraiment serré et boucles chaudes qui sont cruciales pour la performance du système.
la chose intéressante est aussi que la différence de performance est encore si élevée bien que j'ai déjà déroulé la boucle quatre fois. Donc, même si vous déroulez, vous pouvez toujours obtenir frappé par de grands écarts. Tout à fait intéressant.
8 réponses
Faucrit: False Data Dependency (et le compilateur n'en est même pas conscient)
Sur Sandy/Ivy Bridge et Haswell des processeurs, l'instruction:
popcnt src, dest
semble avoir une fausse dépendance sur le registre de destination dest
. Même si l'instruction ne s'écrit qu'à elle, l'instruction attendra jusqu'à ce que dest
soit prêt avant l'exécution.
cette dépendance ne tient pas seulement le 4 popcnt
s d'une itération de boucle simple. Il peut transporter à travers des itérations de boucle rendant impossible pour le processeur de paralléliser différentes itérations de boucle.
le unsigned
vs. uint64_t
et les autres modifications n'affectent pas directement le problème. Mais ils influencent l'allocateur de Registre qui attribue les registres aux variables.
Dans votre cas, les vitesses sont un résultat direct de ce qui est collé à la (false) chaîne de dépendances en fonction de ce que l'allocateur de Registre a décidé de faire.
- 13 GB/ s a une chaîne:
popcnt
-add
-popcnt
-popcnt
→ prochaine itération - 15 GB / s a une chaîne:
popcnt
-add
-popcnt
-add
→ prochaine itération - 20 GB/ s a une chaîne:
popcnt
-popcnt
→ itération suivante - 26 GB/ s a une chaîne:
popcnt
-popcnt
→ itération suivante
la différence entre 20 GB/s et 26 GB/s semble être un artefact mineur de l'adressage indirect. De toute façon, le processeur commence à frapper d'autres goulots d'étranglement, une fois que vous atteindre cette vitesse.
pour tester ceci, j'ai utilisé l'assemblage en ligne pour contourner le compilateur et obtenir exactement l'assemblage que je veux. J'ai aussi divisé la variable count
pour casser toutes les autres dépendances qui peut brouiller les repères.
Voici les résultats:
Sandy Bridge Xeon @ 3.5 GHz: (le code d'essai complet se trouve en bas)
- GCC 4.6.3:
g++ popcnt.cpp -std=c++0x -O3 -save-temps -march=native
- Ubuntu 12
registres différents: 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 , %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 1072, %rax
jne .L4
Même Registre: 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 , %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 1072, %rdx
jne .L9
même registre avec chaîne cassée: 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 , %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 1072, %rdx
jne .L14
qu'est-ce qui a mal tourné avec le compilateur?
il semble que ni GCC ni Visual Studio ne soient au courant que popcnt
a une telle fausse dépendance. Néanmoins, ces fausses dépendances ne sont pas rares. C'est juste une question de si le compilateur est conscient de cela.
popcnt
n'est pas exactement l'instruction la plus utilisée. Donc ce n'est pas vraiment une surprise qu'un compilateur majeur puisse manquer quelque chose comme ça. Il ne semble pas non plus y avoir de documentation où que ce soit qui mentionne ce problème. Si Intel ne le révèle pas, alors personne dehors ne le saura tant que quelqu'un ne l'aura pas rencontré par hasard.
( mise à Jour: à partir de la version 4.9.2 , GCC est conscient de cette fausse dépendance et génère du code pour la compenser lorsque les optimisations sont activées. Les principaux compilateurs d'autres fournisseurs, y compris Clang, MSVC, et même le propre ICC D'Intel ne sont pas encore au courant de cet erratum microarchitectural et n'émettront pas de code qui le compense.)
pourquoi la CPU a-t-elle une fausse dépendance?
nous ne pouvons que spéculer, mais il est probable Qu'Intel a la même manipulation pour beaucoup d'instructions à deux opérandes. Les instructions courantes comme add
, sub
prennent deux opérandes qui sont toutes deux entrées. Donc Intel a probablement mis popcnt
dans la même catégorie pour garder la conception du processeur simple.
ne semblent pas avoir cette fausse dépendance.
le code d'essai complet est ci-dessous pour référence:
#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);
}
Une référence tout aussi intéressante peut être trouvée ici: http://pastebin.com/kbzgL8si
Cette valeur de référence varie le nombre de popcnt
qui font partie de la chaîne de dépendance (fausse).
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
j'ai codé un programme C équivalent à expérimenter, et je peux confirmer ce comportement étrange. De plus, gcc
croit que l'entier 64 bits (qui devrait probablement être un size_t
de toute façon...) afin d'être mieux, que l'utilisation de uint_fast32_t
les causes de gcc pour utiliser une version 64 bits de uint.
J'ai fait un peu de mucking autour de l'Assemblée:
Il suffit de prendre la version 32 bits, remplacer toutes les instructions/registres 32 bits par le Version 64 bits dans la boucle popcount interne du programme. Observation: le code est aussi rapide que la version 32 bits!
C'est évidemment un hack, car la taille de la variable n'est pas vraiment 64 bits, car d'autres parties du programme utilisent encore la version 32 bits, mais aussi longtemps que la boucle popcount interne domine la performance, c'est un bon début.
J'ai ensuite copié la boucle intérieure code à partir de la version 32 bits du programme, a piraté jusqu'à 64 bits, jouait avec les registres pour faire un remplacement de la boucle interne de la version 64 bits. ce code fonctionne aussi vite que la version 32 bits.
Ma conclusion est qu'il s'agit d'une mauvaise planification d'instruction par le compilateur, et non d'un avantage réel de vitesse/latence des instructions 32 bits.
(Mise En Garde: I montage piraté, il aurait pu casser quelque chose sans s'en apercevoir. Je ne le pense pas.)
ce n'est pas une réponse, mais il est difficile de lire si je mets les résultats dans le commentaire.
j'obtiens ces résultats avec un Mac Pro ( Westmere 6-Core Xeon 3.33 GHz). Je l'ai compilé avec clang -O3 -msse4 -lstdc++ a.cpp -o a
(-O2 obtenir le même résultat).
clang avec 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
clang avec uint64_t size=1<<20;
unsigned 41950110000 0.623406 sec 16.8201 GB/s
uint64_t 41950110000 0.623685 sec 16.8126 GB/s
j'ai aussi essayé de:
- Inverser l'ordre de test, le résultat est le même donc il exclut le facteur de cache.
- ont le
for
au verso:for (uint64_t i=size/8;i>0;i-=4)
. Cela donne le même résultat et prouve que la compilation est assez intelligente pour ne pas diviser la taille par 8 à chaque itération (comme prévu).
voici ma supposition sauvage:
le facteur de vitesse est en trois parties:
-
code cache:
uint64_t
version a une plus grande taille de code, mais cela n'a pas d'effet sur mon CPU Xeon. Cela rend la version 64 bits plus lente. -
Instructions utilisées. Notez non seulement le nombre de boucles, mais le tampon est accessible avec un index de 32 bits et 64 bits sur les deux versions. L'accès à un pointeur avec un offset 64 bits demande un registre et une adresse 64 bits dédiés, alors que vous pouvez utiliser immediate for a 32-bits de décalage. Cela pourrait rendre la version 32 bits plus rapide.
Les Instructions -
ne sont émises que sur la compilation 64 bits (c'est-à-dire préfetch). Cela rend 64-bit plus rapide.
les trois facteurs ensemble concordent avec les résultats apparemment contradictoires observés.
Je ne peux pas donner une réponse qui fasse autorité, mais donner un aperçu d'une cause probable. cette référence montre assez clairement que pour les instructions dans le corps de votre boucle il y a un rapport 3:1 entre la latence et le débit. Il montre également les effets d'une expédition multiple. Puisqu'il y a (donner ou prendre) trois unités entières dans les processeurs x86 modernes, il est généralement possible d'envoyer trois instructions par cycle.
donc entre le pic les performances de pipeline et de répartition multiple et la défaillance de ces mécanismes, nous avons un facteur de six dans la performance. Il est assez bien connu que la complexité du jeu d'instructions x86 le rend assez facile pour cassure bizarre de se produire. Le document ci-dessus a un grand exemple:
la performance du Pentium 4 pour les équipes de droite 64 bits est vraiment médiocre. Le poste de gauche de 64 bits ainsi que tous les postes de 32 bits ont des performances acceptables. Il semble que le chemin de données les 32 bits supérieurs aux 32 bits inférieurs de L'ALU ne sont pas bien conçus.
j'ai personnellement rencontré un cas étrange où une boucle chaude courait considérablement plus lentement sur un noyau spécifique d'une puce à quatre noyaux (AMD si je me souviens). Nous avons en fait obtenu de meilleures performances sur une carte-réduire le calcul en éteignant ce noyau.
voici ma supposition est la prétention pour les unités entières: que le popcnt
, compteur de boucle, et les calculs d'adresse peuvent juste exécuter à pleine vitesse avec le compteur 32 bits large, mais le compteur 64 bits provoque des conflits et des décrochages de pipeline. Comme il n'y a qu'environ 12 cycles au total, potentiellement 4 cycles avec régulation multiple, par exécution de corps de boucle, un seul décrochage pourrait raisonnablement affecter le temps d'exécution d'un facteur de 2.
le changement induit par l'utilisation d'une variable statique, qui, je suppose, ne provoque qu'un léger réordonnement des instructions, est un autre indice que le code 32 bits est à un certain point de basculement pour affirmation.
je sais que ce n'est pas une analyse rigoureuse, mais est une explication plausible.
j'ai essayé avec Visual Studio 2013 Express , en utilisant un pointeur au lieu d'un index, ce qui a accéléré le processus un peu. Je soupçonne que c'est parce que l'adressage est compensée + registre, au lieu de décalage + registre + (inscrire<<3). Le code C++.
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;
}
code d'assemblage: r10 = bfrptr, r15 = bfrend, rsi = count, RDI = buffer, r13 =k:
$LL5@main:
mov r10, rdi
cmp rdi, r15
jae SHORT $LN4@main
npad 4
$LL2@main:
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 $LL2@main
$LN4@main:
dec r13
jne SHORT $LL5@main
avez-vous essayé de passer -funroll-loops -fprefetch-loop-arrays
à GCC?
, j'obtiens les résultats suivants avec ces optimisations supplémentaires:
[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
avez-vous essayé de déplacer l'étape de Réduction en dehors de la boucle? Maintenant vous avez une dépendance de données, ce n'est pas vraiment nécessaire.
, Essayez:
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];
vous avez aussi quelques aliasing bizarres en cours, que je ne suis pas sûr de respecter les règles d'aliasing strictes.
TL; DR: utiliser __builtin
intrinsics à la place.
I was able to make gcc
4.8.4 (and even 4.7.3 on gcc.godbolt.org) générez le code optimal pour cela en utilisant __builtin_popcountll
qui utilise la même instruction d'assemblage, mais n'a pas ce bug de fausse dépendance.
Je ne suis pas sûr à 100% de mon code de benchmarking, mais la sortie objdump
semble partager mes vues. J'utilise quelques autres trucs ( ++i
vs i++
) pour faire le compilateur déroulez boucle pour moi sans aucune instruction movl
(comportement étrange, je dois dire).
Résultats:
Count: 20318230000 Elapsed: 0.411156 seconds Speed: 25.503118 GB/s
code de référence:
#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;
}
Compiler options:
gcc --std=gnu99 -mpopcnt -O3 -funroll-loops -march=native bench.c -o bench
version GCC:
gcc (Ubuntu 4.8.4-2ubuntu1~14.04.1) 4.8.4
le noyau Linux version:
3.19.0-58-generic
informations CPU:
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: