Pourquoi mulss ne prend-il que 3 cycles sur Haswell, différent des tableaux d’instructions d’Agner?



assembly optimization (1)

Je suis un débutant en optimisation des instructions.

J'ai fait une analyse simple sur une fonction simple dotp qui est utilisée pour obtenir le produit scalaire de deux tableaux de float.

Le code C est comme suit:

float dotp(               
    const float  x[],   
    const float  y[],     
    const short  n      
)
{
    short i;
    float suma;
    suma = 0.0f;

    for(i=0; i<n; i++) 
    {    
        suma += x[i] * y[i];
    } 
    return suma;
}

J'utilise le cadre de test fourni par Agner Fog sur le testp web.

Les tableaux utilisés dans ce cas sont alignés:

int n = 2048;
float* z2 = (float*)_mm_malloc(sizeof(float)*n, 64);
char *mem = (char*)_mm_malloc(1<<18,4096);
char *a = mem;
char *b = a+n*sizeof(float);
char *c = b+n*sizeof(float);

float *x = (float*)a;
float *y = (float*)b;
float *z = (float*)c;

Ensuite, j'appelle la fonction dotp, n = 2048, répétez = 100000:

 for (i = 0; i < repeat; i++)
 {
     sum = dotp(x,y,n);
 }

Je le compile avec gcc 4.8.3, avec l'option de compilation -O3.

Je compile cette application sur un ordinateur qui ne prend pas en charge les instructions FMA. Vous pouvez donc voir qu'il n'y a que des instructions SSE.

Le code d'assemblage:

.L13:
        movss   xmm1, DWORD PTR [rdi+rax*4]  
        mulss   xmm1, DWORD PTR [rsi+rax*4]   
        add     rax, 1                       
        cmp     cx, ax
        addss   xmm0, xmm1
        jg      .L13

Je fais des analyses:

          μops-fused  la    0    1    2    3    4    5    6    7    
movss       1          3             0.5  0.5
mulss       1          5   0.5  0.5  0.5  0.5
add         1          1   0.25 0.25               0.25   0.25 
cmp         1          1   0.25 0.25               0.25   0.25
addss       1          3         1              
jg          1          1                                   1                                                   -----------------------------------------------------------------------------
total       6          5    1    2     1     1      0.5   1.5

Après avoir couru, on obtient le résultat:

   Clock  |  Core cyc |  Instruct |   BrTaken | uop p0   | uop p1      
--------------------------------------------------------------------
542177906 |609942404  |1230100389 |205000027  |261069369 |205511063 
--------------------------------------------------------------------  
   2.64   |  2.97     | 6.00      |     1     | 1.27     |  1.00   

   uop p2   |    uop p3   |  uop p4 |    uop p5  |  uop p6    |  uop p7       
-----------------------------------------------------------------------   
 205185258  |  205188997  | 100833  |  245370353 |  313581694 |  844  
-----------------------------------------------------------------------          
    1.00    |   1.00      | 0.00    |   1.19     |  1.52      |  0.00           

La deuxième ligne est la valeur lue dans les registres d'Intel; la troisième ligne est divisée par le numéro de branche "BrTaken".

Nous pouvons donc voir que, dans la boucle, il y a 6 instructions, 7 unités, en accord avec l'analyse.

Le nombre d'ops exécutés dans port0 port1 port 5 port6 ​​est similaire à ce que dit l'analyse. Je pense que peut-être que le planificateur d'ops fait cela, il peut essayer d'équilibrer les charges sur les ports, n'est-ce pas?

Je ne comprends absolument pas pourquoi il n’ya qu’environ 3 cycles par boucle. Selon le tableau d'instructions d'Agner, la latence de l'instruction mulss est de 5 et il existe des dépendances entre les boucles. Par conséquent, autant que je mulss , il devrait prendre au moins 5 cycles par boucle.

Quelqu'un pourrait-il donner un aperçu?

=============================================== =================

J'ai essayé d'écrire une version optimisée de cette fonction dans nasm, en déroulant la boucle d'un facteur 8 et en utilisant l'instruction vfmadd231ps :

.L2:
    vmovaps         ymm1, [rdi+rax]             
    vfmadd231ps     ymm0, ymm1, [rsi+rax]       

    vmovaps         ymm2, [rdi+rax+32]          
    vfmadd231ps     ymm3, ymm2, [rsi+rax+32]    

    vmovaps         ymm4, [rdi+rax+64]          
    vfmadd231ps     ymm5, ymm4, [rsi+rax+64]    

    vmovaps         ymm6, [rdi+rax+96]          
    vfmadd231ps     ymm7, ymm6, [rsi+rax+96]   

    vmovaps         ymm8, [rdi+rax+128]         
    vfmadd231ps     ymm9, ymm8, [rsi+rax+128]  

    vmovaps         ymm10, [rdi+rax+160]               
    vfmadd231ps     ymm11, ymm10, [rsi+rax+160] 

    vmovaps         ymm12, [rdi+rax+192]                
    vfmadd231ps     ymm13, ymm12, [rsi+rax+192] 

    vmovaps         ymm14, [rdi+rax+224]                
    vfmadd231ps     ymm15, ymm14, [rsi+rax+224] 
    add             rax, 256                    
    jne             .L2

Le résultat:

  Clock   | Core cyc |  Instruct  |  BrTaken  |  uop p0   |   uop p1  
------------------------------------------------------------------------
 24371315 |  27477805|   59400061 |   3200001 |  14679543 |  11011601  
------------------------------------------------------------------------
    7.62  |     8.59 |  18.56     |     1     | 4.59      |     3.44


   uop p2  | uop p3  |  uop p4  |   uop p5  |   uop p6   |  uop p7  
-------------------------------------------------------------------------
 25960380  |26000252 |  47      |  537      |   3301043  |  10          
------------------------------------------------------------------------------
    8.11   |8.13     |  0.00    |   0.00    |   1.03     |  0.00        

Nous pouvons donc voir que le cache de données L1 atteint 2 * 256 bits / 8,59, il est très proche du pic 2 * 256/8, l'utilisation est d'environ 93%, l'unité FMA utilisée uniquement 8 / 8,59, le pic est de 2 * 8 / 8, l’utilisation est de 47%.

Je pense donc avoir atteint le goulot d'étranglement de la L1D, comme Peter Cordes s'y attendait.

=============================================== =================

Un merci spécial à Boann, corrigez autant d’erreurs grammaticales dans ma question.

=============================================== ===============

D'après la réponse de Peter, je comprends que seul le registre "lu et écrit" constituerait la dépendance, tandis que les registres "réservés aux écrivains" ne le seraient pas.

J'essaie donc de réduire les registres utilisés en boucle, et j'essaye de dérouler par 5, si tout va bien, je devrais rencontrer le même goulot d'étranglement, L1D.

.L2:
    vmovaps         ymm0, [rdi+rax]    
    vfmadd231ps     ymm1, ymm0, [rsi+rax]    

    vmovaps         ymm0, [rdi+rax+32]    
    vfmadd231ps     ymm2, ymm0, [rsi+rax+32]   

    vmovaps         ymm0, [rdi+rax+64]    
    vfmadd231ps     ymm3, ymm0, [rsi+rax+64]   

    vmovaps         ymm0, [rdi+rax+96]    
    vfmadd231ps     ymm4, ymm0, [rsi+rax+96]   

    vmovaps         ymm0, [rdi+rax+128]    
    vfmadd231ps     ymm5, ymm0, [rsi+rax+128]   

    add             rax, 160                    ;n = n+32
    jne             .L2 

Le résultat:

    Clock  | Core cyc  | Instruct  |  BrTaken |    uop p0  |   uop p1  
------------------------------------------------------------------------  
  25332590 |  28547345 |  63700051 |  5100001 |   14951738 |  10549694   
------------------------------------------------------------------------
    4.97   |  5.60     | 12.49     |    1     |     2.93   |    2.07    

    uop p2  |uop p3   | uop p4 | uop p5 |uop p6   |  uop p7 
------------------------------------------------------------------------------  
  25900132  |25900132 |   50   |  683   | 5400909 |     9  
-------------------------------------------------------------------------------     
    5.08    |5.08     |  0.00  |  0.00  |1.06     |     0.00    

Nous pouvons voir que 5 / 5.60 = 89.45%, c’est un peu plus petit que 8, est-ce que quelque chose ne va pas?

=============================================== ===============

J'essaie de dérouler la boucle par 6, 7 et 15, pour voir le résultat. Je me déroule également de 5 à 8 fois pour confirmer le résultat.

Le résultat est comme suit, on peut voir que cette fois le résultat est bien meilleur qu’avant.

Bien que le résultat ne soit pas stable, le facteur de déroulement est plus important et le résultat est meilleur.

            | L1D bandwidth     |  CodeMiss | L1D Miss | L2 Miss 
----------------------------------------------------------------------------
  unroll5   | 91.86% ~ 91.94%   |   3~33    | 272~888  | 17~223
--------------------------------------------------------------------------
  unroll6   | 92.93% ~ 93.00%   |   4~30    | 481~1432 | 26~213
--------------------------------------------------------------------------
  unroll7   | 92.29% ~ 92.65%   |   5~28    | 336~1736 | 14~257
--------------------------------------------------------------------------
  unroll8   | 95.10% ~ 97.68%   |   4~23    | 363~780  | 42~132
--------------------------------------------------------------------------
  unroll15  | 97.95% ~ 98.16%   |   5~28    | 651~1295 | 29~68

=============================================== ====================

J'essaie de compiler la fonction avec gcc 7.1 sur le web " https://gcc.godbolt.org "

L'option de compilation est "-O3 -march = haswell -mtune = intel", ce qui est similaire à gcc 4.8.3.

.L3:
        vmovss  xmm1, DWORD PTR [rdi+rax]
        vfmadd231ss     xmm0, xmm1, DWORD PTR [rsi+rax]
        add     rax, 4
        cmp     rdx, rax
        jne     .L3
        ret

Examinez à nouveau votre boucle: movss xmm1, src ne dépend pas de l'ancienne valeur de xmm1 , car sa destination est en écriture seule . Chaque itération est indépendante. Une exécution dans le mulss peut exploiter et exploite mulss parallélisme au niveau des instructions. Vous ne devez donc absolument pas mulss latence de mulss .

Lecture facultative: En termes d'architecture d'ordinateur: renommer un registre évite le risque que les données d'anti-dépendance de WAR ne réutilisent le même registre architectural. (Certains schémas de pipeline et de suivi de dépendance avant de renommer un registre ne résolvaient pas tous les problèmes, de sorte que le domaine de l’architecture informatique privilégie différents types de risques liés aux données.

Si vous renommez un registre avec l'algorithme de Tomasulo, tout disparaît à l'exception des vraies dépendances (lecture après écriture). Ainsi, toute instruction dans laquelle la destination n'est pas aussi un registre source n'a aucune interaction avec la chaîne de dépendances impliquant l'ancienne valeur de ce registre. (Sauf pour les fausses dépendances, telles que l' popcnt de processeurs Intel , et l'écriture d'une partie d'un registre sans effacer le reste (telles que mov al, 5 ou sqrtss xmm2, xmm1 ). registre de bits ).

Retour à votre code:

.L13:
    movss   xmm1, DWORD PTR [rdi+rax*4]  
    mulss   xmm1, DWORD PTR [rsi+rax*4]   
    add     rax, 1                       
    cmp     cx, ax
    addss   xmm0, xmm1
    jg      .L13

Les dépendances véhiculées par la boucle (d’une itération à la suivante) sont chacune:

  • xmm0 , lu et écrit par addss xmm0, xmm1 , qui a une latence de 3 cycles sur Haswell.
  • rax , lu et écrit par add rax, 1 . 1c latence, donc ce n'est pas le chemin critique.

Il semble que vous ayez correctement mesuré le nombre de cycles / temps d’exécution, car les goulots d’étranglement de la boucle sur la latence d’ addss 3c.

Ceci est attendu: la dépendance en série dans un produit scalaire est l'addition en une somme unique (ou réduction), pas les multiplications entre les éléments vectoriels.

C'est de loin le goulot d'étranglement dominant pour cette boucle, malgré diverses inefficiences mineures:

short i produit le stupide cmp cx, ax , qui prend un préfixe supplémentaire de taille opérande. Heureusement, gcc a réussi à éviter d’ add ax, 1 réellement add ax, 1 , car le dépassement du nombre de signatures est un comportement indéfini en C. L’optimiseur peut donc supposer que cela ne se produira pas . (update: les règles de promotion des nombres entiers le rendent différent , donc UB n'y rentre pas, mais gcc peut toujours légalement optimiser. Trucs plutôt loufoques.)

Si vous aviez compilé avec -mtune=intel , ou mieux, -march=haswell , gcc aurait placé cmp et jg l'un à côté de l'autre, là où ils pourraient fusionner en macro.

Je ne suis pas sûr de savoir pourquoi vous avez un * dans votre tableau sur le cmp et add instructions. (mise à jour: je devinais simplement que vous utilisiez une notation comme le fait IACA , mais apparemment, vous ne l'êtes pas). Ni l'un ni l'autre ne fusionnent. La seule fusion en cours est la micro-fusion de mulss xmm1, [rsi+rax*4] .

Et comme il s'agit d'une instruction ALU à 2 opérandes avec un registre de destination lecture-modification-écriture, elle reste macro-fusionnée même dans le ROB de Haswell. (Sandybridge le décollerait au moment de l’émission.) Notez que vmulss xmm1, xmm1, [rsi+rax*4] sur Haswell .

Rien de tout cela n'a vraiment d'importance, car vous venez de goulot d'étranglement sur la latence d'ajout de FP, beaucoup plus lente que toute limite de débit uop. Sans -ffast-math , les compilateurs ne peuvent rien faire. Avec -ffast-math , clang se déroulera généralement avec plusieurs accumulateurs et se vectorisera automatiquement pour constituer des accumulateurs vectoriels. Donc, vous pouvez probablement saturer la limite de débit de Haswell de 1 vecteur ou scalaire FP ajouté par horloge, si vous utilisez le cache L1D.

Avec une latence de 5c et un débit de 0,5 ° C sur Haswell, il vous faudrait 10 accumulateurs pour maintenir 10 FMA en vol et maximiser le débit de FMA en maintenant p0 / p1 saturé en FMA. (Skylake a réduit la latence FMA à 4 cycles et exécute multiplication, addition et FMA sur les unités FMA. Il a donc une latence additionnelle supérieure à celle de Haswell.)

(Vous êtes goulot d’étranglement sur les charges, car vous avez besoin de deux charges pour chaque FMA. Dans d’autres cas, vous pouvez obtenir un débit supplémentaire en remplaçant une instruction vaddps par une FMA avec un multiplicateur de 1.0. c'est mieux dans un algorithme plus complexe où vous avez un ajout qui n'est pas sur le chemin critique au départ.)

Re: uops par port :

il y a 1,19 uops par boucle dans le port 5, ce qui est beaucoup plus que prévu 0,5, est-ce le problème que le répartiteur uops essaie de créer des uops sur chaque port identique?

Oui, quelque chose comme ça.

Les uops ne sont pas affectés au hasard, ni distribués de manière uniforme sur tous les ports sur lesquels ils pourraient fonctionner. Vous avez supposé que les uops add et cmp se répartiraient également sur p0156, mais ce n'est pas le cas.

L'étape d'édition attribue des uops aux ports en fonction du nombre d'ups déjà en attente pour ce port. Comme addss ne peut être exécuté que sur p1 (et qu’il s’agit du goulot d’étranglement de la boucle), il existe généralement un grand nombre de p1 uops émis mais non exécutés. Donc, peu d'autres uops seront programmés sur port1. (Ceci inclut le mulss : la plupart des mulss mops finiront par être programmés sur le port 0.)

Les branches prises ne peuvent être exécutées que sur le port 6. Le port 5 ne comporte pas d'UPU dans cette boucle, il ne peut donc qu'exploiter. Il finit donc par attirer un grand nombre d'UPU à plusieurs ports.

Le planificateur (qui sélectionne les uops de domaine non fusionnés dans la station de réservation) n’est pas assez intelligent pour exécuter critic-path-first, c’est pourquoi cet algorithme d’affectation réduit le temps de latence des conflits de ressources (les autres uops volent port1 lors de cycles alors qu’un addss pourrait avoir courir). C'est également utile dans les cas où vous rencontrez des problèmes de débit sur un port donné.

La planification des Uops déjà attribués est normalement la plus ancienne, en premier, si je comprends bien. Cet algorithme simple n’a rien d’étonnant, car il doit choisir un uop dont les entrées sont prêtes pour chaque port sur un RS de 60 entrées chaque cycle de l’horloge, sans faire fondre votre CPU. Le mécanisme en panne qui détecte et exploite le protocole ILP est l’un des principaux coûts énergétiques d’un processeur moderne, comparable aux unités d’exécution qui effectuent le travail réel.

Autres informations / connexes: Comment les uops x86 sont-ils planifiés exactement?

Plus d'analyses de performance:

Outre les erreurs de cache / erreurs de branche, les trois principaux goulots d'étranglement possibles pour les boucles liées au processeur sont les suivants:

  • chaînes de dépendance (comme dans ce cas)
  • débit frontal (maximum de 4 UOP à domaine fondu émis par horloge sur Haswell)
  • Les goulots d'étranglement des ports d'exécution, comme si de nombreux uops avaient besoin de p0 / p1 ou de p2 / p3, comme dans votre boucle déroulée. Comptez les uops de domaine non fusionnés pour des ports spécifiques. En règle générale, vous pouvez supposer une distribution optimale, avec des Uops pouvant fonctionner sur d'autres ports ne volant pas très souvent les ports occupés, mais cela se produit parfois.

Un corps de boucle ou un bloc de code court peut être caractérisé de manière approximative par 3 choses: un compte uop de domaine fondu, un compte de domaine non fusionné des unités d'exécution sur lesquelles il peut s'exécuter, et une latence totale du chemin critique en supposant une planification au mieux des cas pour son chemin critique. . (Ou latences de chacune des entrées A / B / C vers la sortie ...)

Par exemple, pour comparer les trois séquences courtes aux trois séquences, voir ma réponse à la question Quelle est la méthode la plus efficace pour compter les bits définis à une position ou à une valeur inférieure?

Pour les boucles courtes, les processeurs modernes disposent de suffisamment de ressources d'exécution dans le désordre (taille du fichier de registre physique pour ne pas renommer les registres, taille ROB) pour avoir suffisamment d'itérations d'une boucle en vol pour retrouver tout le parallélisme. Mais à mesure que les chaînes de dépendance au sein des boucles s'allongent, elles finissent par s'épuiser. Reportez-vous à la section Mesure de la capacité de la mémoire tampon de réorganisation pour plus d'informations sur ce qui se passe lorsqu'un processeur manque de registres pour être renommé.

Voir aussi beaucoup de liens de performance et de référence dans le wiki des balises x86 .

Réglage de votre boucle FMA:

Oui, le produit ponctuel sur Haswell entraînera un goulot d'étranglement sur le débit L1D à un débit deux fois moins élevé que celui des unités FMA, car il faut deux charges par multiplication + addition.

Si vous faisiez B[i] = x * A[i] + y; ou sum(A[i]^2) , vous pourriez saturer le débit de FMA.

Il semble que vous essayiez toujours d'éviter la réutilisation des registres, même dans des cas d'écriture seule, tels que la destination d'un chargement vmovaps . Vous êtes donc à court de registres après un déroulement par 8 . C'est bien, mais cela pourrait avoir de l'importance pour d'autres cas.

En outre, l'utilisation de ymm8-15 peut légèrement augmenter la taille du code si cela signifie qu'un préfixe VEX de 3 octets est nécessaire au lieu de 2 octets. Fait amusant: vpxor ymm7,ymm7,ymm8 nécessite un VEX à 3 octets tandis que vpxor ymm8,ymm8,ymm7 nécessite uniquement un préfixe VEX à 2 octets. Pour les opérations commutatives, triez les registres source de haut en bas.

Notre goulot d'étranglement de charge signifie que le débit maximum de FMA est la moitié du maximum, nous avons donc besoin d'au moins 5 accumulateurs vectoriels pour masquer leur temps de latence. 8 est bon, alors il y a beaucoup de mou dans les chaînes de dépendance pour les laisser rattraper leur retard après tout retard dû à une latence inattendue ou à une compétition pour p0 / p1. 7 ou peut-être même 6 conviendrait également: votre facteur de déroulement ne doit pas nécessairement être une puissance de 2.

Dérouler à exactement 5 signifie que vous êtes également au bon niveau pour les chaînes de dépendance . Chaque fois qu'une FMA ne s'exécute pas dans le cycle exact, son entrée est prête, cela signifie un cycle perdu dans cette chaîne de dépendance. Cela peut arriver si un chargement est lent (par exemple, il manque dans le cache N1 et doit attendre N2), ou si les chargements sont hors d'usage et qu'un FMA d'une autre chaîne de dépendances vole le port pour lequel ce FMA était planifié. (Rappelez-vous que la planification a lieu au moment de l’émission, de sorte que les uops figurant dans le planificateur sont des ports FMA ou OMA port1, et non un FMA pouvant prendre le port inactif.).

Si vous laissez un peu de temps libre dans les chaînes de dépendance, une exécution dans le désordre peut "rattraper" les FMA, car elles ne seront pas gênées par le débit ou la latence, elles n'attendront que les résultats de charge. @Forward a constaté (dans une mise à jour de la question) que le déploiement de 5% réduisait les performances de 93% du débit de la couche L1D à 89,5% pour cette boucle.

Mon hypothèse est que dérouler par 6 (un de plus que le minimum pour masquer la latence) conviendrait ici et donnerait à peu près les mêmes performances que dérouler par 8. Si nous étions plus près de maximiser le débit de FMA (plutôt que de goulot d'étranglement en charge) débit), un de plus que le minimum pourrait ne pas suffire.

mise à jour: le test expérimental de @ Forward montre que ma supposition était fausse . Il n'y a pas une grande différence entre unroll5 et unroll6. En outre, unroll15 est deux fois plus proche que leroll8 du débit maximum théorique de 2x 256b par charge. Mesurer uniquement avec des charges indépendantes dans la boucle, ou avec des charges indépendantes et un FMA à registre uniquement, nous indiquerait dans quelle mesure cela est dû à une interaction avec la chaîne de dépendance du FMA. Même dans le meilleur des cas, le débit ne sera pas parfait à 100%, ne serait-ce qu'en raison d'erreurs de mesure et de perturbations dues aux interruptions de la minuterie. (La perf Linux mesure uniquement les cycles d’espace utilisateur à moins que vous ne l’exécutiez en tant que root, mais le temps inclut toujours le temps passé dans les gestionnaires d’interruptions. C’est pourquoi la fréquence de votre CPU peut être indiquée à 3,87 GHz si elle est exécutée en tant qu’utilisateur non root, mais à 3 900 GHz lors de son exécution. comme cycles racine et de mesure au lieu de cycles:u .)

Nous ne sommes pas goulotés par le débit en amont, mais nous pouvons réduire le nombre d'uop du domaine fondu en évitant les modes d'adressage indexé pour les instructions non- mov . Moins il y a de mieux et cela rend l’utilisation de l’ hyperthreading plus facile lorsque vous partagez un noyau avec autre chose.

La méthode la plus simple consiste à effectuer deux incréments de pointeur dans la boucle. La manière compliquée est une astuce consistant à indexer un tableau par rapport à l'autre:

;; input pointers for x[] and y[] in rdi and rsi
;; size_t n  in rdx

    ;;; zero ymm1..8, or load+vmulps into them

    add             rdx, rsi             ; end_y
    ; lea rdx, [rdx+rsi-252]  to break out of the unrolled loop before going off the end, with odd n

    sub             rdi, rsi             ; index x[] relative to y[], saving one pointer increment

.unroll8:
    vmovaps         ymm0, [rdi+rsi]            ; *px, actually py[xy_offset]
    vfmadd231ps     ymm1, ymm0, [rsi]          ; *py

    vmovaps         ymm0,       [rdi+rsi+32]   ; write-only reuse of ymm0
    vfmadd231ps     ymm2, ymm0, [rsi+32]

    vmovaps         ymm0,       [rdi+rsi+64]
    vfmadd231ps     ymm3, ymm0, [rsi+64]

    vmovaps         ymm0,       [rdi+rsi+96]
    vfmadd231ps     ymm4, ymm0, [rsi+96]

    add             rsi, 256       ; pointer-increment here
                                   ; so the following instructions can still use disp8 in their addressing modes: [-128 .. +127] instead of disp32
                                   ; smaller code-size helps in the big picture, but not for a micro-benchmark

    vmovaps         ymm0,       [rdi+rsi+128-256]  ; be pedantic in the source about compensating for the pointer-increment
    vfmadd231ps     ymm5, ymm0, [rsi+128-256]
    vmovaps         ymm0,       [rdi+rsi+160-256]
    vfmadd231ps     ymm6, ymm0, [rsi+160-256]
    vmovaps         ymm0,       [rdi+rsi-64]       ; or not
    vfmadd231ps     ymm7, ymm0, [rsi-64]
    vmovaps         ymm0,       [rdi+rsi-32]
    vfmadd231ps     ymm8, ymm0, [rsi-32]

    cmp             rsi, rdx
    jb              .unroll8                 ; } while(py < endy);

L'utilisation d'un mode d'adressage non indexé en tant qu'opérande mémoire pour vfmaddps permet de rester micro-fusionné dans le cœur en panne, au lieu d'être non laminé en question. Micro fusion et modes d'adressage

Donc, ma boucle est de 18 Uops à domaine fondu pour 8 vecteurs. Votre vôtre prend 3 uops de domaine fondu pour chaque paire vmovaps + vfmaddps, au lieu de 2, en raison de la non-stratification des modes d'adressage indexé. Bien sûr, les deux ont toujours 2 uops de charge de domaine non fusionnés (port 2/3) par paire, donc ça reste le goulot d’étranglement.

Un nombre moins élevé d'uops de domaine fondu permet aux exécutions dans le désordre de voir plus d'itérations à venir, ce qui l'aidera éventuellement à mieux absorber les erreurs de cache. C'est une chose mineure quand nous sommes goulots d'étranglement sur une unité d'exécution (load uops dans ce cas), même sans cache manquant, cependant. Mais avec l'hyperthreading, vous n'obtenez que tous les deux cycles de bande passante d'émission front-end, à moins que l'autre thread ne soit bloqué. Si ce n'est pas trop concurrentiel pour la charge et p0 / 1, moins d'UPU à domaine fondu permettront à cette boucle de fonctionner plus rapidement tout en partageant un cœur. (Par exemple, l'autre hyper-thread exécute beaucoup de port5 / port6 ​​et stocke des uops?)

Étant donné que la non-lamination se produit après le cache uop, votre version ne prend pas d'espace supplémentaire dans le cache uop. Un disp32 avec chaque uop est ok, et ne prend pas d'espace supplémentaire. Cependant, une taille de code plus volumineuse signifie que le cache uop est moins susceptible de s'emballer aussi efficacement, car vous atteindrez les limites de 32B avant que les lignes du cache uop ne soient pleines plus souvent. (En fait, un code plus petit ne garantit pas non plus un meilleur traitement. Des instructions plus petites peuvent conduire à remplir une ligne de cache uop et à nécessiter une entrée dans une autre ligne avant de franchir une limite de 32B.) Cette petite boucle peut être exécutée à partir de la mémoire tampon de bouclage (LSD). heureusement, le cache uop n'est pas un facteur.

Ensuite, après la boucle: le nettoyage efficace est la partie essentielle de la vectorisation efficace pour les petits tableaux qui peuvent ne pas être un multiple du facteur de déroulage ou en particulier de la largeur du vecteur.

    ...
    jb

    ;; If `n` might not be a multiple of 4x 8 floats, put cleanup code here
    ;; to do the last few ymm or xmm vectors, then scalar or an unaligned last vector + mask.

    ; reduce down to a single vector, with a tree of dependencies
    vaddps          ymm1, ymm2, ymm1
    vaddps          ymm3, ymm4, ymm3
    vaddps          ymm5, ymm6, ymm5
    vaddps          ymm7, ymm8, ymm7

    vaddps          ymm0, ymm3, ymm1
    vaddps          ymm1, ymm7, ymm5

    vaddps          ymm0, ymm1, ymm0

    ; horizontal within that vector, low_half += high_half until we're down to 1
    vextractf128    xmm1, ymm0, 1
    vaddps          xmm0, xmm0, xmm1
    vmovhlps        xmm1, xmm0, xmm0        
    vaddps          xmm0, xmm0, xmm1
    vmovshdup       xmm1, xmm0
    vaddss          xmm0, xmm1
    ; this is faster than 2x vhaddps

    vzeroupper    ; important if returning to non-AVX-aware code after using ymm regs.
    ret           ; with the scalar result in xmm0

Pour plus d'informations sur la somme horizontale à la fin, reportez-vous à la section Méthode la plus rapide pour effectuer une somme vectorielle flottante horizontale sur x86 . Les deux shuffles 128b que j'ai utilisés n'ont même pas besoin d'un octet de contrôle immédiat. Par conséquent, il enregistre 2 octets de la taille du code par rapport aux shufps les plus évidents. (Et 4 octets de taille de code par rapport à vpermilps , car cet opcode nécessite toujours un préfixe VEX de 3 octets ainsi qu'un immédiat). Le fichier AVX à 3 opérandes est très bien comparé au SSE, en particulier lorsque vous écrivez en C avec des éléments intrinsèques, vous ne pouvez donc pas choisir aussi facilement un registre froid dans movhlps vous movhlps .





micro-optimization