Je constate une grande différence de performances entre le code compilé en MSVC (sous Windows) et GCC (sous Linux) pour un système Ivy Bridge. Le code fait une multiplication de matrice dense. J'obtiens 70% des flops de pointe avec GCC et seulement 50% avec MSVC. Je pense que j'ai peut-être isolé la différence dans la façon dont ils convertissent tous les deux les trois intrinsèques suivants.
__m256 breg0 = _mm256_loadu_ps(&b[8*i])
_mm256_add_ps(_mm256_mul_ps(arge0,breg0), tmp0)
GCC fait cela
vmovups ymm9, YMMWORD PTR [rax-256]
vmulps ymm9, ymm0, ymm9
vaddps ymm8, ymm8, ymm9
MSVC fait cela
vmulps ymm1, ymm2, YMMWORD PTR [rax-256]
vaddps ymm3, ymm1, ymm3
Quelqu'un pourrait-il m'expliquer si et pourquoi ces deux solutions pourraient donner une si grande différence de performances?
Bien que MSVC utilise une instruction de moins, il lie la charge au mult et peut-être que cela la rend plus dépendante (peut-être que la charge ne peut pas être effectuée dans le désordre)? Je veux dire que Ivy Bridge peut faire une charge AVX, un AVX mult et un AVX ajouter dans un cycle d'horloge, mais cela nécessite que chaque opération soit indépendante.
Peut-être que le problème réside ailleurs? Vous pouvez voir le code d'assemblage complet pour GCC et MSVC pour la boucle la plus interne ci-dessous. Vous pouvez voir le code C++ pour la boucle ici Déroulement de la boucle pour atteindre un débit maximal avec Ivy Bridge et Haswell
g ++ -S -masm = intel matrix.cpp -O3 -mavx -fopenmp
.L4:
vbroadcastss ymm0, DWORD PTR [rcx+rdx*4]
add rdx, 1
add rax, 256
vmovups ymm9, YMMWORD PTR [rax-256]
vmulps ymm9, ymm0, ymm9
vaddps ymm8, ymm8, ymm9
vmovups ymm9, YMMWORD PTR [rax-224]
vmulps ymm9, ymm0, ymm9
vaddps ymm7, ymm7, ymm9
vmovups ymm9, YMMWORD PTR [rax-192]
vmulps ymm9, ymm0, ymm9
vaddps ymm6, ymm6, ymm9
vmovups ymm9, YMMWORD PTR [rax-160]
vmulps ymm9, ymm0, ymm9
vaddps ymm5, ymm5, ymm9
vmovups ymm9, YMMWORD PTR [rax-128]
vmulps ymm9, ymm0, ymm9
vaddps ymm4, ymm4, ymm9
vmovups ymm9, YMMWORD PTR [rax-96]
vmulps ymm9, ymm0, ymm9
vaddps ymm3, ymm3, ymm9
vmovups ymm9, YMMWORD PTR [rax-64]
vmulps ymm9, ymm0, ymm9
vaddps ymm2, ymm2, ymm9
vmovups ymm9, YMMWORD PTR [rax-32]
cmp esi, edx
vmulps ymm0, ymm0, ymm9
vaddps ymm1, ymm1, ymm0
jg .L4
MSVC/FAc/O2/openmp/Arch: AVX ...
vbroadcastss ymm2, DWORD PTR [r10]
lea rax, QWORD PTR [rax+256]
lea r10, QWORD PTR [r10+4]
vmulps ymm1, ymm2, YMMWORD PTR [rax-320]
vaddps ymm3, ymm1, ymm3
vmulps ymm1, ymm2, YMMWORD PTR [rax-288]
vaddps ymm4, ymm1, ymm4
vmulps ymm1, ymm2, YMMWORD PTR [rax-256]
vaddps ymm5, ymm1, ymm5
vmulps ymm1, ymm2, YMMWORD PTR [rax-224]
vaddps ymm6, ymm1, ymm6
vmulps ymm1, ymm2, YMMWORD PTR [rax-192]
vaddps ymm7, ymm1, ymm7
vmulps ymm1, ymm2, YMMWORD PTR [rax-160]
vaddps ymm8, ymm1, ymm8
vmulps ymm1, ymm2, YMMWORD PTR [rax-128]
vaddps ymm9, ymm1, ymm9
vmulps ymm1, ymm2, YMMWORD PTR [rax-96]
vaddps ymm10, ymm1, ymm10
dec rdx
jne SHORT $LL3@AddDot4x4_
MODIFIER:
Je compare le code en claculant le total des opérations en virgule flottante comme 2.0*n^3
Où n est la largeur de la matrice carrée et en divisant par le temps mesuré avec omp_get_wtime()
. Je répète la boucle plusieurs fois. Dans la sortie ci-dessous, je l'ai répété 100 fois.
La sortie de MSVC2012 sur un turbo Intel Xeon E5 1620 (Ivy Bridge) pour tous les cœurs est de 3,7 GHz
maximum GFLOPS = 236.8 = (8-wide SIMD) * (1 AVX mult + 1 AVX add) * (4 cores) * 3.7 GHz
n 64, 0.02 ms, GFLOPs 0.001, GFLOPs/s 23.88, error 0.000e+000, efficiency/core 40.34%, efficiency 10.08%, mem 0.05 MB
n 128, 0.05 ms, GFLOPs 0.004, GFLOPs/s 84.54, error 0.000e+000, efficiency/core 142.81%, efficiency 35.70%, mem 0.19 MB
n 192, 0.17 ms, GFLOPs 0.014, GFLOPs/s 85.45, error 0.000e+000, efficiency/core 144.34%, efficiency 36.09%, mem 0.42 MB
n 256, 0.29 ms, GFLOPs 0.034, GFLOPs/s 114.48, error 0.000e+000, efficiency/core 193.37%, efficiency 48.34%, mem 0.75 MB
n 320, 0.59 ms, GFLOPs 0.066, GFLOPs/s 110.50, error 0.000e+000, efficiency/core 186.66%, efficiency 46.67%, mem 1.17 MB
n 384, 1.39 ms, GFLOPs 0.113, GFLOPs/s 81.39, error 0.000e+000, efficiency/core 137.48%, efficiency 34.37%, mem 1.69 MB
n 448, 3.27 ms, GFLOPs 0.180, GFLOPs/s 55.01, error 0.000e+000, efficiency/core 92.92%, efficiency 23.23%, mem 2.30 MB
n 512, 3.60 ms, GFLOPs 0.268, GFLOPs/s 74.63, error 0.000e+000, efficiency/core 126.07%, efficiency 31.52%, mem 3.00 MB
n 576, 3.93 ms, GFLOPs 0.382, GFLOPs/s 97.24, error 0.000e+000, efficiency/core 164.26%, efficiency 41.07%, mem 3.80 MB
n 640, 5.21 ms, GFLOPs 0.524, GFLOPs/s 100.60, error 0.000e+000, efficiency/core 169.93%, efficiency 42.48%, mem 4.69 MB
n 704, 6.73 ms, GFLOPs 0.698, GFLOPs/s 103.63, error 0.000e+000, efficiency/core 175.04%, efficiency 43.76%, mem 5.67 MB
n 768, 8.55 ms, GFLOPs 0.906, GFLOPs/s 105.95, error 0.000e+000, efficiency/core 178.98%, efficiency 44.74%, mem 6.75 MB
n 832, 10.89 ms, GFLOPs 1.152, GFLOPs/s 105.76, error 0.000e+000, efficiency/core 178.65%, efficiency 44.66%, mem 7.92 MB
n 896, 13.26 ms, GFLOPs 1.439, GFLOPs/s 108.48, error 0.000e+000, efficiency/core 183.25%, efficiency 45.81%, mem 9.19 MB
n 960, 16.36 ms, GFLOPs 1.769, GFLOPs/s 108.16, error 0.000e+000, efficiency/core 182.70%, efficiency 45.67%, mem 10.55 MB
n 1024, 17.74 ms, GFLOPs 2.147, GFLOPs/s 121.05, error 0.000e+000, efficiency/core 204.47%, efficiency 51.12%, mem 12.00 MB
Puisque nous avons couvert le problème d'alignement, je suppose que c'est ceci: http://en.wikipedia.org/wiki/Out-of-order_execution
Étant donné que g ++ émet une instruction de chargement autonome, votre processeur peut réorganiser les instructions pour pré-récupérer les données suivantes qui seront nécessaires tout en les ajoutant et en les multipliant. MSVC lancer un pointeur sur mul rend la charge et mul liés à la même instruction, donc changer l'ordre d'exécution des instructions n'aide en rien.
EDIT: Les serveurs d'Intel avec tous les documents sont moins en colère aujourd'hui, alors voici plus de recherches sur les raisons pour lesquelles l'exécution hors service est (une partie de) la réponse.
Tout d'abord, il semble que votre commentaire ait tout à fait raison sur le fait qu'il est possible pour la version MSVC de l'instruction de multiplication de décoder pour séparer les µ-ops qui peuvent être optimisés par le moteur hors service d'un CPU. La partie amusante ici est que les séquenceurs de microcodes modernes sont programmables, donc le comportement réel dépend à la fois du matériel et du micrologiciel. Les différences dans l'assemblage généré semblent provenir du GCC et du MSVC, chacun essayant de lutter contre différents goulots d'étranglement potentiels. La version GCC essaie de donner une marge de manœuvre au moteur hors service (comme nous l'avons déjà vu). Cependant, la version MSVC finit par profiter d'une fonctionnalité appelée "fusion micro-op". Cela est dû aux limites de retraite µ-op. La fin du pipeline ne peut retirer que 3 µ-ops par tick. La fusion micro-op, dans des cas spécifiques, prend deux µ-op qui doivent être effectuées sur deux unités d'exécution différentes (c.-à-d. Lecture de mémoire et arithmétique) et des liens les à un seul µ-op pour la plupart du pipeline. Le µ-op fusionné n'est divisé qu'en deux µ-op réels juste avant l'affectation de l'unité d'exécution. Après l'exécution, les opérations sont fusionnées à nouveau, ce qui leur permet d'être retirées en une seule.
Le moteur hors service ne voit que le µ-op fusionné, il ne peut donc pas retirer l'op de charge de la multiplication. Cela entraîne le blocage du pipeline en attendant que l'opérande suivant termine son trajet en bus.
TOUS LES LIENS !!!: http://download-software.intel.com/sites/default/files/managed/71/2e/319433-017.pdf
http://www.agner.org/optimize/microarchitecture.pdf
http://www.agner.org/optimize/optimizing_Assembly.pdf
http://www.agner.org/optimize/instruction_tables.ods (REMARQUE: Excel se plaint que cette feuille de calcul est partiellement corrompue ou autrement sommaire, alors ouvrez-la à vos risques et périls. Elle ne semble pas être malveillant, cependant, et selon le reste de mes recherches, Agner Fog est génial. Après avoir opté pour l'étape de récupération d'Excel, je l'ai trouvé plein de tonnes d'excellentes données)
http://www.syncfusion.com/Content/downloads/ebook/Assembly_Language_Succinctly.pdf
BEAUCOUP PLUS MODIFICATION: Wow, il y a eu une mise à jour intéressante de la discussion ici. Je suppose que je me suis trompé sur la quantité de pipeline qui est réellement affectée par la fusion micro-op. Peut-être y a-t-il plus de gain de perf que ce que j'attendais des différences dans la vérification de l'état de la boucle, où les instructions non fusionnées permettent à GCC d'entrelacer la comparaison et de sauter avec la dernière charge vectorielle et les étapes arithmétiques?
vmovups ymm9, YMMWORD PTR [rax-32]
cmp esi, edx
vmulps ymm0, ymm0, ymm9
vaddps ymm1, ymm1, ymm0
jg .L4
Je peux confirmer que l'utilisation du code GCC dans Visual Studio améliore effectivement les performances. J'ai fait cela en conversion du fichier objet GCC sous Linux pour qu'il fonctionne dans Visual Studio . L'efficacité est passée de 50% à 60% en utilisant les quatre cœurs (et de 60% à 70% pour un seul cœur).
Microsoft a supprimé l'assembly en ligne du code 64 bits et aussi cassé leur dissembleur 64 bits afin que le code ne puisse pas être ressemblé sans modification ( mais la version 32 bits fonctionne toujours =). Ils pensaient évidemment que les intrinsèques seraient suffisantes, mais comme le montre ce cas, ils ont tort.
Peut-être que les instructions fusionnées devraient être intrinsèques distinctes?
Mais Microsoft n'est pas le seul à produire du code intrinsèque moins optimal. Si vous mettez le code ci-dessous dans http://gcc.godbolt.org/ vous pouvez voir ce que font Clang, ICC et GCC. ICC a donné des performances encore pires que MSVC. Il utilise vinsertf128
mais je ne sais pas pourquoi. Je ne suis pas sûr de ce que fait Clang, mais il semble être plus proche de GCC juste dans un ordre différent (et plus de code).
Cela explique pourquoi Agner Fog a écrit dans son manuel " Optimizing subroutines in Assembly language " en ce qui concerne "les inconvénients de l'utilisation des fonctions intrinsèques":
Le compilateur peut modifier le code ou l'implémenter d'une manière moins efficace que prévu par le programmeur. Il peut être nécessaire de regarder le code généré par le compilateur pour voir s'il est optimisé de la manière prévue par le programmeur.
C'est décevant pour le cas de l'utilisation intrinsèque. Cela signifie que l'on doit toujours écrire du code d'assemblage 64 bits en temps réel ou trouver un compilateur qui implémente les intrinsèques comme le programmeur le souhaitait. Dans ce cas, seul GCC semble le faire (et peut-être Clang).
#include <immintrin.h>
extern "C" void AddDot4x4_vec_block_8wide(const int n, const float *a, const float *b, float *c, const int stridea, const int strideb, const int stridec) {
const int vec_size = 8;
__m256 tmp0, tmp1, tmp2, tmp3, tmp4, tmp5, tmp6, tmp7;
tmp0 = _mm256_loadu_ps(&c[0*vec_size]);
tmp1 = _mm256_loadu_ps(&c[1*vec_size]);
tmp2 = _mm256_loadu_ps(&c[2*vec_size]);
tmp3 = _mm256_loadu_ps(&c[3*vec_size]);
tmp4 = _mm256_loadu_ps(&c[4*vec_size]);
tmp5 = _mm256_loadu_ps(&c[5*vec_size]);
tmp6 = _mm256_loadu_ps(&c[6*vec_size]);
tmp7 = _mm256_loadu_ps(&c[7*vec_size]);
for(int i=0; i<n; i++) {
__m256 areg0 = _mm256_set1_ps(a[i]);
__m256 breg0 = _mm256_loadu_ps(&b[vec_size*(8*i + 0)]);
tmp0 = _mm256_add_ps(_mm256_mul_ps(areg0,breg0), tmp0);
__m256 breg1 = _mm256_loadu_ps(&b[vec_size*(8*i + 1)]);
tmp1 = _mm256_add_ps(_mm256_mul_ps(areg0,breg1), tmp1);
__m256 breg2 = _mm256_loadu_ps(&b[vec_size*(8*i + 2)]);
tmp2 = _mm256_add_ps(_mm256_mul_ps(areg0,breg2), tmp2);
__m256 breg3 = _mm256_loadu_ps(&b[vec_size*(8*i + 3)]);
tmp3 = _mm256_add_ps(_mm256_mul_ps(areg0,breg3), tmp3);
__m256 breg4 = _mm256_loadu_ps(&b[vec_size*(8*i + 4)]);
tmp4 = _mm256_add_ps(_mm256_mul_ps(areg0,breg4), tmp4);
__m256 breg5 = _mm256_loadu_ps(&b[vec_size*(8*i + 5)]);
tmp5 = _mm256_add_ps(_mm256_mul_ps(areg0,breg5), tmp5);
__m256 breg6 = _mm256_loadu_ps(&b[vec_size*(8*i + 6)]);
tmp6 = _mm256_add_ps(_mm256_mul_ps(areg0,breg6), tmp6);
__m256 breg7 = _mm256_loadu_ps(&b[vec_size*(8*i + 7)]);
tmp7 = _mm256_add_ps(_mm256_mul_ps(areg0,breg7), tmp7);
}
_mm256_storeu_ps(&c[0*vec_size], tmp0);
_mm256_storeu_ps(&c[1*vec_size], tmp1);
_mm256_storeu_ps(&c[2*vec_size], tmp2);
_mm256_storeu_ps(&c[3*vec_size], tmp3);
_mm256_storeu_ps(&c[4*vec_size], tmp4);
_mm256_storeu_ps(&c[5*vec_size], tmp5);
_mm256_storeu_ps(&c[6*vec_size], tmp6);
_mm256_storeu_ps(&c[7*vec_size], tmp7);
}
MSVC a fait exactement ce que vous lui aviez demandé. Si vous voulez qu'une instruction vmovups
soit émise, utilisez le _mm256_loadu_ps
intrinsèque.