web-dev-qa-db-fra.com

Différence de performances entre MSVC et GCC pour un code de multiplication de matrice hautement optimisé

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
31
Z boson

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.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.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://cs.nyu.edu/courses/fall13/CSCI-GA.3033-008/Microprocessor-Report-Sandy-Bridge-Spans-Generations-243901.pdf

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
21
iwolf

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);
}
6
Z boson

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.

3
Ben Voigt