Je cherchais le moyen le plus rapide de popcount
de grands tableaux de données. J'ai rencontré un effet très bizarre : le changement de la variable de boucle de unsigned
à uint64_t
a entraîné une chute des performances de 50% sur mon PC.
#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);
}
Comme vous le voyez, nous créons un tampon de données aléatoires, de taille x
mégaoctets, où x
est lu à partir de la ligne de commande. Ensuite, nous parcourons la mémoire tampon et utilisons une version non déroulée de x86 popcount
intrinsic pour effectuer le décompte contextuel. Pour obtenir un résultat plus précis, nous effectuons le décompte pop-up 10 000 fois. Nous mesurons les temps pour le compte de pop. En majuscule, la variable de boucle interne est unsigned
, en minuscule, la variable de boucle interne est uint64_t
. Je pensais que cela ne devrait faire aucune différence, mais l'inverse est le cas.
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 sur mon HaswellCore i7-4770K CPU à 3.50 GHz, en cours d'exécution test 1
(donc 1 Mo de données aléatoires):
Comme vous le voyez, le débit de la version uint64_t
n'est que seulement la moitié de celui de la version unsigned
! Le problème semble être que différentes assemblées sont générées, mais pourquoi? D'abord, j'ai pensé à un bug du compilateur, alors 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
Donc, c'est presque le même résultat et c'est toujours étrange. Mais maintenant cela devient très étrange. Je remplace la taille de la mémoire tampon lue depuis l'entrée par une constante 1
, alors je change:
uint64_t size = atol(argv[1]) << 20;
à
uint64_t size = 1 << 20;
Ainsi, le compilateur connaît maintenant la taille de la mémoire tampon au moment de la compilation. Peut-être qu'il peut ajouter quelques optimisations! Voici les chiffres pour g++
:
Maintenant, les deux versions sont également rapides. Cependant, la unsigned
devenait encore plus lente ! Il est passé de 26
à 20 GB/s
, ce qui a remplacé une non constante par une valeur constante, ce qui a entraîné une désoptimisation . Sérieusement, je n'ai aucune idée de ce qui se passe ici! Mais maintenant, passons à clang++
avec la nouvelle version:
Attendez quoi? Les deux versions sont passées au nombre lent de 15 Go/s. Ainsi, le remplacement d'une constante non constante par une valeur constante conduit même à ralentir le code dans les deux cas de Clang!
J'ai demandé à un collègue avec un Ivy Bridge CPU de compiler mon benchmark. Il a eu des résultats similaires, donc ça ne semble pas être Haswell. Parce que deux compilateurs produisent des résultats étranges ici, cela ne semble pas non plus être un bogue du compilateur. Nous n'avons pas de processeur AMD ici, nous ne pouvions donc tester qu'avec Intel.
Prenez le premier exemple (celui avec atol(argv[1])
) et mettez un static
avant la variable, c'est-à-dire:
static uint64_t size=atol(argv[1])<<20;
Voici mes résultats en g ++:
Oui, encore une autre alternative . Nous avons toujours le débit rapide de 26 Go/s avec u32
, mais nous avons réussi à obtenir u64
au moins de 13 Go/s à la version 20 Go/s! Sur le PC de mon collègue, la version u64
est devenue encore plus rapide que la version u32
, produisant le résultat le plus rapide. Malheureusement , cela ne fonctionne que pour g++
, clang++
ne semble pas se soucier de static
.
Pouvez-vous expliquer ces résultats? Notamment:
u32
et u64
?static
peut-elle accélérer la boucle u64
? Encore plus vite que le code d'origine sur l'ordinateur de mon collègue!Je sais que l'optimisation est un domaine difficile, cependant, je n'ai jamais pensé que de tels changements minimes puissent conduire à une différence de temps d'exécution de 100% comme une taille de mémoire tampon constante, les résultats peuvent être totalement mélangés. Bien sûr, je veux toujours avoir la version capable de gérer 26 Go/s. Le seul moyen fiable auquel je puisse penser est de copier/coller l’Assemblée pour ce cas et d’utiliser l’Assemblée en ligne. C’est la seule façon pour moi de me débarrasser des compilateurs qui semblent devenir fous après de petits changements. Qu'est-ce que tu penses? Existe-t-il un autre moyen d'obtenir de manière fiable le code le plus performant?
Voici le démontage pour les différents résultats:
Version de 26 Go/s à partir de 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
Version de 13 Go/s à partir de 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
Version 15 Go/s de 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
Version de 20 Go/s à partir 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 $0x20,%rdx
add %rsi,%rcx
add %rcx,%rbp
cmp $0x100000,%rdx
jne 0x400a68
Version 15 Go/s de 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
Fait intéressant, la version la plus rapide (26 Go/s) est aussi la plus longue! Cela semble être la seule solution qui utilise lea
. Certaines versions utilisent jb
pour sauter, d'autres utilisent jne
. Mais à part ça, toutes les versions semblent comparables. Je ne vois pas d'où pourrait provenir un écart de performance de 100%, mais je ne suis pas trop habile à déchiffrer Assembly. La version la plus lente (13 Go/s) semble même très courte et bonne. Quelqu'un peut-il expliquer cela?
Peu importe ce que la réponse à cette question sera; J'ai appris que dans les boucles vraiment chaudes , tous les détails peuvent avoir une importance, , même les détails qui ne semblent pas être associés au code actif Je n'ai jamais pensé au type à utiliser pour une variable de boucle, mais comme vous le voyez, un changement aussi mineur peut faire une différence de à 100% ! 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 différentes alternatives sur différents compilateurs lors de l'écriture de boucles très serrées et très critiques, essentielles pour les performances du système.
Ce qui est intéressant, c’est aussi que la différence de performances est toujours aussi importante bien que j’ai déjà déroulé la boucle quatre fois. Ainsi, même si vous vous déroulez, vous pouvez toujours subir d'importants écarts de performance. Plutôt interessant.
Coupable: Faux dépendances de données (et le compilateur n'en est même pas conscient)
Sur les processeurs Sandy/Ivy Bridge et Haswell, l'instruction:
popcnt src, dest
semble avoir une fausse dépendance sur le registre de destination dest
. Même si l’instruction n’y écrit qu’elle, elle attendra que dest
soit prêt avant de s’exécuter.
Cette dépendance ne tient pas seulement en place les 4 popcnt
s d'une itération à boucle unique. Il peut supporter des itérations de boucle, ce qui empêche le processeur de paralléliser différentes itérations de boucle.
Le unsigned
vs. uint64_t
et d'autres réglages n'affectent pas directement le problème. Mais ils influencent l’allocateur de registres qui assigne les registres aux variables.
Dans votre cas, les vitesses résultent directement de ce qui est collé à la (fausse) chaîne de dépendance en fonction de ce que l'allocateur de registre a décidé de faire.
popcnt
-add
-popcnt
-popcnt
→ itération suivantepopcnt
-add
-popcnt
-add
→ itération suivantepopcnt
-popcnt
→ itération suivantepopcnt
-popcnt
→ itération suivanteLa différence entre 20 Go/s et 26 Go/s semble être un artefact mineur de l'adressage indirect. Dans les deux cas, le processeur commence à rencontrer d'autres goulots d'étranglement lorsque vous atteignez cette vitesse.
Pour tester cela, j'ai utilisé Assembly en ligne pour contourner le compilateur et obtenir exactement l'assembly souhaité. J'ai également séparé la variable count
pour casser toutes les autres dépendances susceptibles de gâcher les repères.
Voici les résultats:
Sandy Bridge Xeon @ 3.5 GHz: (le code de test complet se trouve en bas)
g++ popcnt.cpp -std=c++0x -O3 -save-temps -march=native
Différents registres: 18.6195 Go/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
Même registre: 8.49272 Go/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
Même registre avec chaîne brisée: 17,8869 Go/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
Alors, qu'est-ce qui ne va pas avec le compilateur?
Il semble que ni GCC ni Visual Studio ne sachent que popcnt
a une dépendance aussi fausse. Néanmoins, ces fausses dépendances ne sont pas rares. C'est juste une question de savoir si le compilateur est au courant.
popcnt
n'est pas exactement l'instruction la plus utilisée. Ce n'est donc pas vraiment une surprise qu'un grand compilateur puisse manquer quelque chose comme ça. Il semble également n'y avoir aucune documentation mentionnant ce problème à un endroit quelconque. Si Intel ne le divulgue pas, personne à l'extérieur ne le saura jusqu'à ce que quelqu'un le rencontre par hasard.
( Mise à jour: À partir de la version 4.9.2 , GCC est conscient de cette fausse dépendance et génère un code pour la compenser lorsque les principaux compilateurs d’autres fournisseurs, dont 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 le compensant.)
Pourquoi le processeur a-t-il une dépendance aussi fausse?
Nous ne pouvons que spéculer, mais il est probable qu'Intel ait le même traitement pour de nombreuses instructions à deux opérandes. Des instructions communes telles que add
, sub
prennent deux opérandes, qui sont tous deux des entrées. Ainsi, Intel a probablement poussé popcnt
dans la même catégorie pour simplifier la conception du processeur.
Les processeurs AMD ne semblent pas avoir cette fausse dépendance.
Le code de test 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
Ce repère fait varier le nombre de popcnt
s qui se trouvent dans la chaîne (fausse) de dépendance.
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 le nombre entier 64 bits (qui devrait probablement être un size_t
de toute façon ...) est meilleur, car utiliser uint_fast32_t
force gcc à utiliser un uint 64 bits. .
Je me suis un peu amusé avec l’Assemblée:
Prenez simplement la version 32 bits, remplacez toutes les instructions/registres 32 bits par la 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 de 64 bits, car d’autres parties du programme utilisent toujours la version 32 bits, mais tant que la boucle popcount interne domine les performances, C'est un bon début.
J'ai ensuite copié le code de la boucle interne de la version 32 bits du programme, je l'ai piraté en 64 bits, manipulé les registres pour le remplacer par la boucle interne de la version 64 bits. Ce code est aussi rapide que la version 32 bits.
Ma conclusion est qu’il s’agit d’une mauvaise planification des instructions par le compilateur, et non d’un avantage en termes de vitesse/latence des instructions 32 bits.
(Attention: j'ai piraté l’Assemblée, j’aurais pu casser quelque chose sans m'en rendre compte. Je ne le pense pas.)
Ce n'est pas une réponse, mais c'est difficile à lire si je mets les résultats en commentaire.
Je reçois ces résultats avec un Mac Pro ( Westmere 6-Cores Xeon 3.33 GHz). Je l'ai compilé avec clang -O3 -msse4 -lstdc++ a.cpp -o a
(-O2 obtient le même résultat).
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
J'ai aussi essayé de:
for
à l'envers: for (uint64_t i=size/8;i>0;i-=4)
. Cela donne le même résultat et prouve que la compilation est suffisamment intelligente pour ne pas diviser la taille par 8 à chaque itération (comme prévu).Voici ma supposition sauvage:
Le facteur de vitesse est composé de trois parties:
cache de code: uint64_t
la version a une taille de code plus grande, mais cela n'a pas d'effet sur mon processeur Xeon. Cela ralentit la version 64 bits.
Instructions utilisées. Notez non seulement le nombre de boucles, mais l'accès à la mémoire tampon avec un index 32 bits et 64 bits sur les deux versions. L'accès à un pointeur avec un décalage de 64 bits demande un registre et un adressage 64 bits dédiés, alors que vous pouvez utiliser une valeur immédiate pour un décalage de 32 bits. Cela peut rendre la version 32 bits plus rapide.
Les instructions ne sont émises que sur la compilation 64 bits (c'est-à-dire la pré-extraction). Cela rend 64 bits plus rapide.
Les trois facteurs concordent avec les résultats apparemment contradictoires observés.
J'ai essayé ceci avec Visual Studio 2013 Express , en utilisant un pointeur au lieu d'un index, ce qui a un peu accéléré le processus. J'imagine que c'est parce que l'adressage est offset + registre, au lieu de offset + registre + (registre << 3). 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 = compte, rdi = tampon, 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
Je ne peux pas donner de réponse faisant autorité, mais donner un aperçu d'une cause probable. Cette référence montre assez clairement que, pour les instructions figurant dans le corps de votre boucle, le rapport entre latence et débit est de 3: 1. Il montre également les effets de l'envoi multiple. Comme il existe (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.
Ainsi, entre les performances de pipeline maximales et les performances de dispatching multiples et les défaillances de ces mécanismes, nous avons un facteur six en performances. Il est bien connu que la complexité du jeu d’instructions x86 facilite la survenue de ruptures bizarres. Le document ci-dessus a un excellent exemple:
Les performances du Pentium 4 pour les décalages à droite 64 bits sont vraiment médiocres. Les décalages à gauche 64 bits ainsi que tous les décalages 32 bits ont des performances acceptables. Il semble que le chemin de données entre les 32 bits supérieurs et les 32 bits inférieurs de l’ALU n’est pas bien conçu.
J'ai personnellement rencontré un cas étrange dans lequel une boucle chaude fonctionnait considérablement plus lentement sur un noyau spécifique d'une puce à quatre cœurs (AMD si je me souviens bien). Nous avons en fait obtenu de meilleures performances avec un calcul de réduction de carte en désactivant ce noyau.
Ici, mon hypothèse est la contention pour les unités entières: que les calculs de compteurs de boucles et d'adresses popcnt
peuvent à peine fonctionner à la vitesse maximale avec le compteur de largeur 32 bits, mais que le compteur de 64 bits provoque des conflits et des blocages de pipeline . Puisqu'il n'y a qu'environ 12 cycles au total, potentiellement 4 cycles avec plusieurs répartitions, par exécution de corps de boucle, un décrochage peut affecter raisonnablement le temps d'exécution d'un facteur 2.
Le changement induit par l'utilisation d'une variable statique, qui, je suppose, ne provoque qu'un réordonnancement mineur des instructions, est un autre indice du fait que le code 32 bits est à un point critique pour la contention.
Je sais que ce n'est pas une analyse rigoureuse, mais c'est une explication plausible .
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? Actuellement, vous avez une dépendance de données qui n’est vraiment pas nécessaire.
Essayer:
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 également des alias bizarres, dont je ne suis pas sûr qu’ils soient conformes aux règles strictes relatives aux alias.
TL; DR: utilisez __builtin
intrinsics à la place.
J'ai pu faire en sorte que gcc
4.8.4 (et même 4.7.3 sur gcc.godbolt.org) génère le code optimal pour cela en utilisant __builtin_popcountll
qui utilise la même instruction Assembly, mais n'a pas ce faux bug de dépendance.
Je ne suis pas sûr à 100% de mon code d'analyse comparative, mais la sortie objdump
semble partager mon point de vue. J'utilise d'autres astuces (++i
vs i++
) pour que le compilateur se déroule en boucle sans instruction movl
(comportement étrange, je dois dire).
Résultats:
Count: 20318230000 Elapsed: 0.411156 seconds Speed: 25.503118 GB/s
Code d'analyse comparative:
#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;
}
Options de compilation:
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
Version du noyau Linux:
3.19.0-58-generic
Informations sur le processeur:
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: