Je cherchais le moyen le plus rapide d'accéder à de popcount
grands tableaux de données. J'ai rencontré un effet très étrange : changer la variable de boucle de unsigned
à a uint64_t
fait chuter les performances 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 << "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, avec la taille étant x
mégaoctets où x
est lu à partir de la ligne de commande. Ensuite, nous parcourons le tampon et utilisons une version déroulée de l' popcount
intrinsèque x86 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. 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 pas faire de différence, mais le contraire est le cas.
Les résultats (absolument fous)
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 processeur Haswell Core i7-4770K à 3,50 GHz, en cours d'exécution test 1
(donc 1 Mo de données aléatoires):
- non signé 41959360000 0,401554 s 26,113 Go / s
- uint64_t 41959360000 0,759822 sec 13,8003 Go / s
Comme vous le voyez, le débit de la uint64_t
version n'est que la moitié de celui de la unsigned
version! Le problème semble être que différents assemblages sont générés, mais pourquoi? J'ai d'abord pensé à un bug du compilateur, j'ai donc 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 Go / s
- uint64_t 41959360000 0,680954 s 15,3986 Go / s
Donc, c'est presque le même résultat et c'est toujours étrange. Mais maintenant, ça devient super étrange. Je remplace la taille de la mémoire tampon qui a été lue depuis l'entrée par une constante 1
, je change donc:
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 que cela peut ajouter des optimisations! Voici les chiffres pour g++
:
- non signé 41959360000 0,509156 s 20,5944 Go / s
- uint64_t 41959360000 0,508673 sec 20,6139 Go / s
Maintenant, les deux versions sont également rapides. Cependant, cela est unsigned
devenu encore plus lent ! Elle est passée de 26
à 20 GB/s
, remplaçant ainsi une valeur 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 s 15,4884 Go / s
- uint64_t 41959360000 0,676909 sec 15,4906 Go / s
Attends quoi? Maintenant, les deux versions ont chuté au lent nombre de 15 Go / s. Ainsi, remplacer une non constante par une valeur constante conduit même à un code lent dans les deux cas pour Clang!
J'ai demandé à un collègue avec un processeur Ivy Bridge de compiler mon benchmark. Il a obtenu des résultats similaires, il ne semble donc pas s'agir de Haswell. Parce que deux compilateurs produisent des résultats étranges ici, il 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.
Plus de folie, s'il vous plaît!
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 ++:
- non signé 41959360000 0,396728 s 26,4306 Go / s
- uint64_t 41959360000 0,509484 sec 20,5811 Go / s
Oui, encore une autre alternative . Nous avons encore le rapide 26 Go / s avec u32
, mais nous avons réussi à passer u64
au moins de la version 13 Go / s à la version 20 Go / s! Sur le PC de mon collègue, la u64
version est devenue encore plus rapide que la u32
version, donnant le résultat le plus rapide de tous. Malheureusement, cela ne fonctionne que pour g++
, clang++
ne semble pas se soucier static
.
Ma question
Pouvez-vous expliquer ces résultats? Surtout:
- Comment peut-il y avoir une telle différence entre
u32
etu64
? - Comment le remplacement d'un non constant par une taille de tampon constante peut-il déclencher un code moins optimal ?
- Comment l'insertion du
static
mot - clé peut-elleu64
accélérer la boucle? Encore plus rapide que le code d'origine 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 si petits changements peuvent entraîner une différence de 100% dans le temps d'exécution 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 Go / s. Le seul moyen fiable auquel je peux penser est de copier-coller l'assemblage pour ce cas et d'utiliser l'assemblage en ligne. C'est la seule façon dont je peux me débarrasser des compilateurs qui semblent devenir fous de petits changements. Qu'est-ce que tu penses? Existe-t-il un autre moyen d'obtenir de manière fiable le code avec la plupart des performances?
Le démontage
Voici le démontage des différents résultats:
Version 26 Go / s de g ++ / u32 / non const 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 13 Go / s de g ++ / u64 / bufsize non const :
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 à partir de bufsize clang ++ / u64 / non const :
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 20 Go / s 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 également la plus longue! Il semble que ce soit la seule solution qui utilise lea
. Certaines versions utilisent jb
pour sauter, d'autres utilisent jne
. Mais à part cela, toutes les versions semblent être comparables. Je ne vois pas d'où pourrait provenir un écart de performance de 100%, mais je ne suis pas trop habile pour 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 détail peut être important, même les détails qui ne semblent pas avoir d'association avec le code chaud . 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 static
mot - clé devant la variable de taille! À l'avenir, je testerai toujours différentes alternatives sur différents compilateurs lors de l'écriture de boucles vraiment serrées et chaudes qui sont cruciales pour les performances du système.
La chose intéressante est aussi que la différence de performance est toujours aussi élevée même si j'ai déjà déroulé la boucle quatre fois. Ainsi, même si vous vous déroulez, vous pouvez toujours être frappé par des écarts de performances majeurs. Plutôt interessant.