Remplacer un compteur de boucle de 32 bits par 64 bits introduit des écarts de performances insensés

Je cherchais le moyen le plus rapide de popcount grandes popcount de données. J’ai rencontré un effet très étrange : changer la variable loop de unsigned à uint64_t fait chuter les performances de 50% sur mon PC.

La référence

 #include  #include  #include  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(buffer); for (unsigned i=0; i<size; ++i) charbuffer[i] = rand()%256; uint64_t count,duration; chrono::time_point 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(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(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, 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 du popcount x86 pour effectuer le comptage. Pour obtenir un résultat plus précis, nous effectuons le décompte de 10 000 fois. Nous mesurons les temps pour le compte. Dans la majuscule, la variable de boucle interne est unsigned , dans la minuscule, la variable de boucle interne est uint64_t . Je pensais que cela ne devrait faire aucune différence, mais le contraire est le cas.

Les résultats (absolument fous)

Je le comstack 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 exécutant le test 1 (donc 1 Mo de données aléatoires):

  • non signé 41959360000 0.401554 sec 26.113 Go / s
  • uint64_t 41959360000 0,759822 sec 13,8003 Go / s

Comme vous le voyez, le débit de la version uint64_t n’est que la moitié de la version unsigned ! Le problème semble être que différents assemblages sont générés, mais pourquoi? D’abord, j’ai pensé à un bogue de compilateur, j’ai donc essayé clang++ (version Ubuntu Clang 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 sec 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 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 peut-il append quelques optimisations! Voici les nombres pour g++ :

  • non signé 41959360000 0,509156 sec 20,5944 Go / s
  • uint64_t 41959360000 0.508673 sec 20.6139 Go / s

Maintenant, les deux versions sont également rapides. Cependant, les unsigned ralentissent encore ! Il est passé de 26 à 20 GB/s , remplaçant ainsi une non-constante par une valeur constante entraînant 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 Go / s
  • uint64_t 41959360000 0.676909 sec 15.4906 Go / s

Attends quoi? Maintenant, les deux versions sont tombées à un nombre lent de 15 Go / s. Ainsi, remplacer une non-constante par une valeur constante conduit même à ralentir le code dans les deux cas pour Clang!

J’ai demandé à un collègue avec un processeur Ivy Bridge de comstackr mon benchmark. Il a obtenu des résultats similaires, donc il ne semble pas être Haswell. Comme deux compilateurs produisent des résultats étranges ici, cela ne semble pas non plus être un bogue de compilation. Nous n’avons pas de CPU AMD ici, donc nous ne pouvons que tester avec Intel.

Plus de folie, s’il vous plait!

Prenez le premier exemple (celui avec atol(argv[1]) ) et placez un static avant la variable, à savoir:

 static uint64_t size=atol(argv[1])<<20; 

Voici mes résultats en g ++:

  • non signé 41959360000 0,396728 sec 26,4306 Go / s
  • uint64_t 41959360000 0.509484 sec 20.5811 Go / s

Yay, encore une autre alternative . Nous avons toujours le rapide 26 Go / s avec u32 , mais nous avons réussi à obtenir u64 au moins de la version 13 Go / s à la version 20 Go / s! Sur le PC de mon u64 , 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 la static .

Ma question

Pouvez-vous expliquer ces résultats? Notamment:

  • Comment peut-il y avoir une telle différence entre u32 et u64 ?
  • Comment le remplacement d’une non-constante par une taille de mémoire tampon constante peut-il déclencher un code moins optimal ?
  • Comment l’insertion du mot-clé static -il rendre la boucle u64 plus rapide? Encore plus rapide que le code d’origine sur l’ordinateur de mon collègue!

Je sais que l’optimisation est un domaine délicat, mais je n’ai jamais pensé que de tels petits changements puissent entraîner une différence de 100% dans le temps d’exécution et que de petits facteurs comme une taille de mémoire tampon constante peuvent à nouveau mélanger les résultats. Bien sûr, je veux toujours avoir la version capable de compter 26 Go / s. Le seul moyen fiable auquel je peux penser est de copier l’assemblage pour ce cas et d’utiliser un assemblage en ligne. C’est le seul moyen de 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 le code de manière fiable avec le plus de performances?

Le déassembly

Voici le déassembly des 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 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 é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ù un écart de performance de 100% pourrait provenir, 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 apsockets

Quelle que soit la réponse à cette question sera; J’ai appris que dans les boucles très chaudes, chaque détail peut avoir de l’importance, même les détails qui ne semblent pas avoir de lien avec le code chaud . Je n’ai jamais pensé à quel type utiliser pour une variable de boucle, mais comme vous voyez un changement si 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 ssortingctes et chaudes qui sont cruciales pour les performances du système.

Ce qui est intéressant, c’est 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 vous déroulez, vous pouvez toujours être frappé par des écarts de performance importants. Plutôt interessant.

    Coupable: fausse dépendance des données (et le compilateur n’en est même pas conscient)

    Sur les processeurs Sandy / Ivy Bridge et Haswell, les instructions:

     popcnt src, dest 

    semble avoir une fausse dépendance sur le registre de destination dest . Même si l’instruction n’y écrit que, l’instruction attendra que dest soit prête avant son exécution.

    Cette dépendance ne retient pas seulement les 4 popcnt d’une itération en boucle unique. Il peut transporter plusieurs itérations de boucle, ce qui empêche le processeur de paralléliser différentes itérations de boucle.

    Les unsigned vs. uint64_t et les autres réglages n’affectent pas directement le problème. Mais ils influencent l’allocateur de registre qui assigne les registres aux variables.

    Dans votre cas, les vitesses sont le résultat direct de ce qui est collé à la chaîne de dépendance (faux) en fonction de ce que l’allocateur de registre a décidé de faire.

    • 13 Go / s a ​​une chaîne: popcntaddpopcntpopcnt → prochaine itération
    • 15 Go / s a ​​une chaîne: popcntaddpopcntadd → prochaine itération
    • 20 Go / s ont une chaîne: popcntpopcnt → prochaine itération
    • 26 Go / s ont une chaîne: popcntpopcnt → prochaine itération

    La différence entre 20 Go / s et 26 Go / s semble être un artefact mineur de l’adressage indirect. De toute façon, le processeur commence à atteindre d’autres goulots d’étranglement une fois que vous avez atteint cette vitesse.


    Pour tester cela, j’ai utilisé un assemblage en ligne pour contourner le compilateur et obtenir exactement l’assemblage souhaité. J’ai également divisé la variable count pour casser toutes les autres dépendances qui pourraient perturber les tests.

    Voici les résultats:

    Sandy Bridge Xeon @ 3.5 GHz: (le code de test 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 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 soient conscients que popcnt a une telle fausse dépendance. Néanmoins, ces fausses dépendances ne sont pas rares. C’est juste une question de savoir si le compilateur en est conscient.

    popcnt n’est pas exactement l’instruction la plus utilisée. Donc, ce n’est pas vraiment une surprise qu’un grand compilateur puisse rater quelque chose comme ça. Il semble également qu’il n’y ait aucune documentation mentionnant ce problème. Si Intel ne le divulgue pas, personne à l’extérieur ne le saura avant que quelqu’un ne 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 du code pour le compenser lorsque les optimisations sont activées. Les principaux compilateurs d’autres fournisseurs, y compris Clang, MSVC cet erratum microarchitectural et n’émettra pas de code qui le compense.)

    Pourquoi le processeur a-t-il une telle fausse dépendance?

    Nous ne pouvons que spéculer, mais il est probable qu’Intel ait le même traitement pour beaucoup d’instructions à deux opérandes. Des instructions communes comme add , sub prennent deux opérandes, toutes deux entrées. Donc, Intel a probablement poussé popcnt dans la même catégorie pour garder la conception du processeur simple.

    Les processeurs AMD ne semblent pas avoir cette fausse dépendance.


    Le code de test complet est ci-dessous pour référence:

     #include  #include  #include  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(buffer); for (unsigned i=0;i 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(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(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(endP-startP).count(); cout << "Broken Chain\t" << count << '\t' << (duration/1.0E9) << " sec \t" << (10000.0*size)/(duration) << " GB/s" << endl; } free(charbuffer); } 

    Un benchmark tout aussi intéressant peut être trouvé ici: http://pastebin.com/kbzgL8si
    Ce benchmark varie le nombre de popcnt 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 pour expérimenter et je peux confirmer ce comportement étrange. De plus, gcc estime que le nombre entier de 64 bits (qui devrait être de toute façon une size_t ) sera meilleur, car l’utilisation de uint_fast32_t amène gcc à utiliser un uint de 64 bits.

    J’ai passé un peu de temps avec l’assemblage:
    Prenez simplement la version 32 bits, remplacez toutes les instructions / registres 32 bits par la version 64 bits de la boucle de comptage 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, les autres parties du programme utilisant toujours la version 32 bits, mais tant que la boucle de popcount interne domine les performances, c’est un bon début .

    J’ai ensuite copié le code de la boucle interne à partir de la version 32 bits du programme, l’ai piraté pour qu’il soit 64 bits, mélangé avec les registres pour en faire un remplacement de la boucle interne de la version 64 bits. Ce code s’exécute aussi rapidement que la version 32 bits.

    Ma conclusion est que c’est une mauvaise planification des instructions par le compilateur, pas un avantage réel de vitesse / latence des instructions 32 bits.

    (Mise en garde: j’ai piraté l’assemblage, j’ai pu briser 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 -oa (-O2 obtient 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:

    1. Inverser l'ordre de test, le résultat est le même, donc il exclut le facteur de cache.
    2. Avoir l'instruction for en sens inverse: 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 se décline en trois parties:

    • cache de code: la version de uint64_t a une taille de code plus grande, mais cela n'a aucun 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 32 bits et 64 bits sur les deux versions. L'access à un pointeur avec un décalage de 64 bits requirejs un registre et un adressage 64 bits dédiés, tandis que vous pouvez utiliser immédiatement un décalage 32 bits. Cela peut rendre la version 32 bits plus rapide.

    • Les instructions ne sont émises que sur la compilation 64 bits (préfecture). Cela rend 64 bits plus rapide.

    Les trois facteurs concordent avec les résultats apparemment contradictoires observés.

    Je ne peux pas donner de réponse faisant autorité, mais donner un aperçu d’une cause probable. Cette référence montre clairement que pour les instructions dans le corps de votre boucle, il existe un rapport de 3: 1 entre la latence et le débit. Il montre également les effets de la répartition multiple. Comme il existe 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 optimales du pipeline et les répartitions multiples et la défaillance de ces mécanismes, nous avons un facteur six en termes de performances. Il est bien connu que la complexité du jeu d’instructions x86 facilite grandement la casse. Le document ci-dessus a un bon exemple:

    Les performances du Pentium 4 pour les changements de 64 bits à droite sont vraiment médiocres. Le décalage gauche de 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 ne soit pas bien conçu.

    J’ai personnellement rencontré un cas étrange où une boucle chaude était considérablement plus lente sur un kernel spécifique d’une puce à quatre cœurs (si je me souviens bien). Nous avons en fait obtenu de meilleurs résultats sur un calcul de réduction de carte en désactivant ce kernel.

    popcnt que les unités entières sont en popcnt : les popcnt , loop et address peuvent tous s’exécuter à toute vitesse avec le compteur de 32 bits, mais le compteur 64 bits provoque des popcnt et des problèmes de pipeline. Comme il n’y a qu’environ 12 cycles au total, potentiellement 4 cycles avec envoi multiple, par exécution de corps de boucle, un seul décrochage pourrait raisonnablement affecter le temps d’exécution d’un facteur 2.

    Le changement induit par l’utilisation d’une variable statique, que j’imagine juste provoquer un réordonnancement mineur des instructions, est un autre indice que le code 32 bits est en quelque sorte à la limite de la contention.

    Je sais que ce n’est pas une parsing 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 

    Je l’ai essayé avec Visual Studio 2013 Express , en utilisant un pointeur au lieu d’un index, ce qui a accéléré un peu le processus. Je soupçonne que c’est parce que l’adressage est offset + register, au lieu de offset + register + (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(endP-startP).count(); cout << "uint64_t\t" << count << '\t' << (duration/1.0E9) << " sec \t" << (10000.0*size)/(duration) << " GB/s" << endl; } 

    code assembleur: r10 = bfrptr, r15 = bfrend, rsi = compte, 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 déplacer le pas de réduction en dehors de la boucle? En ce moment, 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 un aliasing bizarre, que je ne suis pas sûr d'être conforme aux règles ssortingctes d'alias.

    TL; DR: utilisez __builtin les __builtin insortingnsèques de __builtin .

    J’ai pu faire en sorte que gcc 4.8.4 (et même 4.7.3 sur gcc.godbolt.org) génère du code optimal pour cela en utilisant __builtin_popcountll qui utilise la même instruction d’assemblage, mais ne contient pas ce faux bug de dépendance.

    Je ne suis pas sûr à 100% de mon code de benchmarking, mais la sortie objdump semble partager mes vues. J’utilise d’autres astuces ( ++i vs i++ ) pour que le compilateur déroule la boucle sans aucune instruction movl (comportement étrange, je dois le dire).

    Résultats:

     Count: 20318230000 Elapsed: 0.411156 seconds Speed: 25.503118 GB/s 

    Code de benchmarking:

     #include  #include  #include  #include  #include  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 \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 kernel 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: