320 votes

Pourquoi ce code s'exécute-t-il plus lentement après avoir réduit les multiplications à des additions portées par la boucle ?

Je suis en train de lire les manuels d'optimisation d'Agner Fog, et je suis tombé sur cet exemple :

double data[LEN];

void compute()
{
    const double A = 1.1, B = 2.2, C = 3.3;

    int i;
    for(i=0; i<LEN; i++) {
        data[i] = A*i*i + B*i + C;
    }
}

Agner indique qu'il existe un moyen d'optimiser ce code - en réalisant que la boucle peut éviter d'utiliser des multiplications coûteuses, et utiliser plutôt les "deltas" qui sont appliqués par itération.

J'utilise une feuille de papier pour confirmer la théorie, d'abord...

Theory

...et bien sûr, il a raison - à chaque itération de la boucle, nous pouvons calculer le nouveau résultat sur la base de l'ancien, en ajoutant un "delta". Ce delta commence à la valeur "A+B", et est ensuite incrémenté de "2*A" à chaque étape.

Nous mettons donc à jour le code pour qu'il ressemble à ceci :

void compute()
{
    const double A = 1.1, B = 2.2, C = 3.3;
    const double A2 = A+A;
    double Z = A+B;
    double Y = C;

    int i;
    for(i=0; i<LEN; i++) {
        data[i] = Y;
        Y += Z;
        Z += A2;
    }
}

En termes de complexité opérationnelle, la différence entre ces deux versions de la fonction est en effet frappante. Les multiplications ont la réputation d'être significativement plus lentes dans nos CPUs, comparées aux additions. Et nous avons remplacé 3 multiplications et 2 additions... par seulement 2 additions !

Donc j'y vais et j'ajoute une boucle pour exécuter compute un grand nombre de fois - et ensuite garder le temps minimum qu'il a fallu pour l'exécuter :

unsigned long long ts2ns(const struct timespec *ts)
{
    return ts->tv_sec * 1e9 + ts->tv_nsec;
}

int main(int argc, char *argv[])
{
    unsigned long long mini = 1e9;
    for (int i=0; i<1000; i++) {
        struct timespec t1, t2;
        clock_gettime(CLOCK_MONOTONIC_RAW, &t1);
        compute();
        clock_gettime(CLOCK_MONOTONIC_RAW, &t2);
        unsigned long long diff = ts2ns(&t2) - ts2ns(&t1);
        if (mini > diff) mini = diff;
    }
    printf("[-] Took: %lld ns.\n", mini);
}

Je compile les deux versions, je les exécute... et je vois ceci :

# gcc -O3 -o 1 ./code1.c

# gcc -O3 -o 2 ./code2.c

# ./1
[-] Took: 405858 ns.

# ./2
[-] Took: 791652 ns.

Eh bien, c'est inattendu. Comme nous rapportons le temps d'exécution minimum, nous éliminons le "bruit" causé par diverses parties du système d'exploitation. Nous avons également pris soin de l'exécuter dans une machine qui ne fait absolument rien. Et les résultats sont plus ou moins reproductibles - la ré-exécution des deux binaires montre que le résultat est cohérent :

# for i in {1..10} ; do ./1 ; done
[-] Took: 406886 ns.
[-] Took: 413798 ns.
[-] Took: 405856 ns.
[-] Took: 405848 ns.
[-] Took: 406839 ns.
[-] Took: 405841 ns.
[-] Took: 405853 ns.
[-] Took: 405844 ns.
[-] Took: 405837 ns.
[-] Took: 406854 ns.

# for i in {1..10} ; do ./2 ; done
[-] Took: 791797 ns.
[-] Took: 791643 ns.
[-] Took: 791640 ns.
[-] Took: 791636 ns.
[-] Took: 791631 ns.
[-] Took: 791642 ns.
[-] Took: 791642 ns.
[-] Took: 791640 ns.
[-] Took: 791647 ns.
[-] Took: 791639 ns.

La seule chose à faire ensuite est de voir quel type de code le compilateur a créé pour chacune des deux versions.

objdump -d -S montre que la première version de compute - le code "débile", mais en quelque sorte rapide - a une boucle qui ressemble à ceci :

objdump naive

Qu'en est-il de la deuxième version, optimisée, qui ne fait que deux ajouts ?

objdump optimized

Je ne sais pas pour vous, mais en ce qui me concerne, je suis... perplexe. La seconde version a environ 4 fois moins d'instructions, les deux principales étant juste des ajouts basés sur SSE ( addsd ). La première version, non seulement a 4 fois plus d'instructions... elle est aussi pleine (comme prévu) de multiplications ( mulpd ).

J'avoue que je ne m'attendais pas à ce résultat. Non pas parce que je suis un fan d'Agner (je le suis, mais cela n'a rien à voir).

Une idée de ce que j'ai manqué ? Ai-je commis une erreur qui pourrait expliquer la différence de vitesse ? Notez que j'ai fait le test sur un Xeon W5580 et un Xeon E5 1620 - dans les deux cas, la première version (muette) est beaucoup plus rapide que la seconde.

EDIT : Pour faciliter la reproduction des résultats, j'ai ajouté deux gists avec les deux versions du code : Stupide mais en quelque sorte plus rapide y optimisé, mais en quelque sorte plus lent .

P.S. Veuillez ne pas commenter les problèmes de précision de la virgule flottante ; ce n'est pas le but de cette question.

314voto

Sean Werkema Points 165

La clé pour comprendre les différences de performances que vous observez se trouve dans vectorisation . Oui, la solution basée sur l'addition ne comporte que deux instructions dans sa boucle interne, mais la différence importante ne se situe pas au niveau de l'instruction de la boucle interne. combien de instructions qu'il y a dans la boucle, mais dans combien de travail chaque instruction est exécutée.

Dans la première version, la sortie est purement dépendante de l'entrée : Chaque data[i] est une fonction juste de i lui-même, ce qui signifie que chaque data[i] peuvent être calculés dans n'importe quel ordre : Le compilateur peut les faire en avant, en arrière, latéralement, peu importe, et vous obtiendrez toujours le même résultat - à moins que vous n'observiez cette mémoire depuis un autre thread, vous ne remarquerez jamais dans quel sens les données sont traitées.

Dans la deuxième version, la sortie ne dépend pas de i - il dépend de la A y Z de la dernière fois que vous avez fait le tour de la boucle.

Si nous devions représenter les corps de ces boucles comme de petites fonctions mathématiques, ils auraient des formes globales très différentes :

  • f(i) -> di
  • f(Y, Z) -> (di, Y', Z')

Dans cette dernière forme, il n'y a pas de dépendance réelle à l'égard de i - la seule façon de calculer la valeur de la fonction est de connaître la valeur précédente Y y Z à partir de la dernière invocation de la fonction, ce qui signifie que les fonctions forment une chaîne - vous ne pouvez pas exécuter la suivante avant d'avoir exécuté la précédente.

En quoi cela est-il important ? Parce que le CPU a vecteur parallèle des instructions qui chaque peut effectuer quatre opérations arithmétiques en même temps ! (Les processeurs AVX peuvent en faire encore plus en parallèle.) Cela représente quatre multiplications, quatre additions, quatre soustractions, quatre comparaisons - quatre choses ! Donc, si le résultat que vous essayez de calculer est sólo en fonction de l'entrée, vous pouvez sans risque en faire quatre à la fois - peu importe qu'ils soient en avant ou en arrière, puisque le résultat est le même. Mais si la sortie dépend de calcul préalable alors vous devez le faire en série, un par un.

C'est pourquoi le code le plus long gagne en performance. Même s'il a beaucoup plus d'installation, et qu'il est en fait en faisant beaucoup plus de travail, la plupart de ce travail est fait en parallèle : Ce n'est pas seulement le calcul data[i] à chaque itération de la boucle - c'est calculer data[i] , data[i+1] , data[i+2] y data[i+3] en même temps, puis de passer à la série suivante de quatre.

Pour développer un peu ce que je veux dire ici, le compilateur a d'abord transformé le code original en quelque chose comme ceci :

int i;
for (i = 0; i < LEN; i += 4) {
    data[i+0] = A*(i+0)*(i+0) + B*(i+0) + C;
    data[i+1] = A*(i+1)*(i+1) + B*(i+1) + C;
    data[i+2] = A*(i+2)*(i+2) + B*(i+2) + C;
    data[i+3] = A*(i+3)*(i+3) + B*(i+3) + C;
}

Vous pouvez vous convaincre que ça fera la même chose que l'original, si vous louchez. Il l'a fait à cause de toutes ces lignes verticales identiques d'opérateurs : Toutes ces * y + sont la même opération, juste exécutée sur des données différentes - et le processeur a des instructions spéciales intégrées qui peuvent effectuer quatre opérations. * ou quatre + sur différentes données en même temps, en un seul cycle d'horloge chacun.

Remarquez la lettre p dans les instructions de la solution plus rapide - addpd y mulpd - et la lettre s dans les instructions de la solution plus lente - addsd . C'est "Ajouter des doubles emballés" et "Multiplier des doubles emballés", par opposition à "Ajouter un simple double".

En plus de cela, il semble que le compilateur ait partiellement déroulé la boucle aussi - la boucle ne fait pas seulement quatre à chaque itération, mais en réalité huit et a entrelacé les opérations pour éviter les dépendances et les blocages, ce qui réduit le nombre de fois où le code d'assemblage doit être testé. i < 1000 également.

Tout cela ne fonctionne, cependant, que s'il y a aucune dépendance entre les itérations de la boucle : Si la seule chose qui détermine ce qui se passe pour chaque data[i] est i lui-même. S'il y a des dépendances, si les données de la dernière itération influencent la suivante, alors le compilateur peut être tellement contraint par celles-ci qu'il ne peut pas modifier le code du tout - au lieu que le compilateur puisse utiliser des instructions parallèles fantaisistes ou des optimisations intelligentes (CSE, réduction de la force, déroulement de boucle, réordonnancement, etc.), vous obtenez un code qui est exactement ce que vous y avez mis - ajouter Y, puis ajouter Z, puis répéter.

Mais ici, dans la première version du code, le compilateur a correctement reconnu qu'il n'y avait pas de dépendances dans les données, et a compris qu'il pouvait effectuer le travail en parallèle, ce qu'il a fait, et c'est ce qui fait toute la différence.

65voto

Chris Dodd Points 39013

La principale différence réside dans les dépendances de la boucle. La boucle dans le second cas est dépendant -- les opérations dans la boucle dépendent de l'itération précédente. Cela signifie que chaque itération ne peut même pas commencer tant que l'itération précédente n'est pas terminée. Dans le premier cas, le corps de la boucle est entièrement indépendant -- Tout ce qui se trouve dans le corps de la boucle est autonome et ne dépend que du compteur d'itérations et des valeurs constantes. Cela signifie que la boucle peut être calculée en parallèle - plusieurs itérations peuvent fonctionner en même temps. Cela permet ensuite à la boucle d'être trivialement déroulée et vectorisée, en chevauchant de nombreuses instructions.

Si vous regardiez les compteurs de performance (par exemple avec perf stat ./1 ), vous verrez que la première boucle, en plus de tourner plus vite, exécute également beaucoup plus d'instructions par cycle (IPC). La deuxième boucle, en revanche, a plus de cycles de dépendance, c'est-à-dire le temps pendant lequel l'unité centrale ne fait rien et attend que les instructions soient terminées avant de pouvoir en envoyer d'autres.

La première peut entraîner un goulot d'étranglement au niveau de la bande passante mémoire, surtout si vous laissez le compilateur auto-vectoriser avec AVX sur votre Sandybridge ( gcc -O3 -march=native ), s'il parvient à utiliser des vecteurs de 256 bits. À ce moment-là, l'IPC chutera, surtout pour un tableau de sortie trop grand pour le cache L3.


Une remarque, le déroulage et la vectorisation ne sont pas exiger boucles indépendantes -- vous pouvez les faire lorsque (certaines) dépendances de boucle sont présentes. Cependant, il est plus difficile et le gain est moindre. Ainsi, si vous voulez bénéficier d'un gain de vitesse maximal grâce à la vectorisation, il est préférable de supprimer les dépendances des boucles lorsque cela est possible.

42voto

Peter Cordes Points 1375

Cette optimisation peut donner un gain de vitesse par rapport à ce que l'on peut faire de mieux en réévaluant le polynôme séparément pour chaque i . Mais seulement si vous le généralisez à un pas plus grand, pour avoir encore assez de parallélisme dans la boucle. Ma version peut stocker 1 vecteur (4 doubles) par cycle d'horloge sur mon Skylake. . Ou en théorie 1 vecteur pour 2 horloges sur les Intel antérieurs, y compris Sandybridge où un stockage de 256 bits prend 2 cycles d'horloge dans le port de stockage.
Il est évident qu'il doit tenir dans le cache pour fonctionner aussi rapidement, donc répétez un petit LEN plusieurs fois.


Ce site optimisation de la réduction de la force (il suffit d'ajouter au lieu de commencer avec un nouveau i et en multipliant) introduit une dépendance sérielle entre les itérations de la boucle impliquant des calculs de type FP plutôt que des incréments entiers.

L'original est a parallélisme des données sur chaque élément de sortie chacun ne dépend que des constantes et de sa propre i valeur. Les compilateurs peuvent auto-vectoriser avec SIMD (SSE2, ou AVX si vous utilisez -O3 -march=native ), et les CPU peuvent chevaucher le travail entre les itérations de la boucle avec une exécution hors ordre. Malgré la quantité de travail supplémentaire, le CPU est capable d'appliquer une force brute suffisante, avec l'aide du compilateur.

Mais la version qui calcule poly(i+1) en termes de poly(i) a un parallélisme très limité ; pas de vectorisation SIMD, et votre CPU ne peut exécuter que deux additions scalaires par 4 cycles, par exemple, où 4 cycles est la latence de l'addition FP sur Intel Skylake à Tiger Lake. ( https://uops.info/ ).


La réponse de @huseyin tugrul buyukisik montre comment on peut se rapprocher du débit maximal de la version originale sur un CPU plus moderne, avec deux opérations FMA pour évaluer le polynôme (schéma de Horner), plus une conversion int->FP ou un incrément FP. (Ce dernier crée une chaîne FP dep qu'il faut dérouler pour la cacher).

Dans le meilleur des cas, vous avez donc 3 opérations mathématiques FP par vecteur SIMD de sortie. (Plus un stockage). Les CPU Intel actuels n'ont que deux unités d'exécution FP qui peuvent exécuter des opérations mathématiques FP. (Avec des vecteurs de 512 bits avec AVX-512, les CPU actuels ferment l'ALU vectorielle sur le port 1, il n'y a donc que 2 ports SIMD ALU, donc les opérations mathématiques non FP comme l'incrémentation d'un entier seront également en concurrence pour le débit).

Depuis Zen2, AMD a deux unités FMA/mul sur deux ports, et deux unités FP add/sub sur deux ports différents, donc si vous utilisez FMA pour faire l'addition, vous avez un maximum théorique de quatre additions SIMD par cycle d'horloge.

Haswell/Broadwell ont un FMA à 2/clock, mais seulement un FP add/sub à 1/clock (avec une latence plus faible). C'est une bonne chose pour le code naïf, pas génial pour le code qui a été optimisé pour avoir beaucoup de parallélisme. C'est probablement pour cela qu'Intel l'a modifié dans Skylake.

Vos processeurs Sandybridge (E5-1620) et Nehalem (W5580) ont un FP add/sub 1/clock, un FP mul 1/clock, sur des ports séparés. C'est ce sur quoi Haswell s'est basé. Et pourquoi l'ajout de multiplications supplémentaires n'est pas un gros problème : elles peuvent fonctionner en parallèle avec les additions existantes.

Trouver le parallélisme : réduction de la force avec une foulée arbitraire

Votre compute2 calcule le prochain Y et le prochain Z en fonction de la valeur immédiatement précédente, c'est-à-dire qu'avec un stride de 1, les valeurs dont vous avez besoin pour data[i+1] . Ainsi, chaque itération est dépendante de celle qui la précède immédiatement.

Si vous généralisez cela à d'autres foulées, vous pouvez avancer 4, 8, 16 ou plus de valeurs Y et Z distinctes, de sorte qu'elles sautent toutes au même rythme, indépendamment les unes des autres. Cela permet de retrouver un certain parallélisme dont le compilateur et/ou le CPU peuvent tirer parti.

poly(i)   = A i^2             + B i          + C

poly(i+4) = A (i+4)^2         + B (i+4)      + C

          = A*i^2 + A*8i + A*16  +  B i + 4B   + C
          = poly(i) + 4*(2A)*i + 16*A  + 4B

(J'ai choisi un 4 en partie parce que l'écriture des exposants est désordonnée dans un texte ASCII. Sur le papier, j'aurais pu simplement faire (i+s)^2 = i^2 + 2is + s^2 et ainsi de suite. Une autre approche consisterait à prendre les différences entre les points successifs, puis les différences de ces différences. Du moins, c'est là que nous voulons aboutir, avec des valeurs que nous pouvons ajouter pour générer la séquence (de valeurs FP). En gros, cela revient à prendre la 1ère et la 2ème dérivée, et ensuite la boucle optimisée est effectivement en train d'intégrer pour récupérer la fonction originale).

  • La partie de la 2ème dérivée (anciennement Z += A*2 )
    devient Z[j] += A * (stride * 2); de la A * 8i terme.
  • La croissance linéaire (autrefois le Z = A+B; )
    devient (A*stride + B) * stride de la 16*A + 4*B sur l'avancement par 4.

Comme vous pouvez le constater, pour stride=1 (pas de déroulage), ceux-ci se simplifient aux valeurs originales. Mais avec des stride mais ils laissent quand même cette méthode fonctionner, sans multiplications supplémentaires ou autre chose dans la boucle.

// UNTESTED, but compiles to asm that looks right.
// Hopefully I got the math right.

#include <stdalign.h>
#include <stdlib.h>
#define LEN 1024
alignas(64) double data[LEN];

void compute2_unrolled(void)
{
    const double A = 1.1, B = 2.2, C = 3.3;
    const int stride = 16;   // unroll factor.  1 reduces to the original
    const double deriv2 = A * (stride * 2);
    double Z[stride], Y[stride];
    for (int j = 0 ; j<stride ; j++){  // this loop will fully unroll
            Y[j] = j*j*A + j*B + C;         // poly(j) starting values to increment
            Z[j] = (A*stride + B) * stride;  // 1st derivative = same initial Z for all elements
    }

    for(size_t i=0; i < LEN - (stride-1); i+=stride) {
#if 0
        for (int j = 0 ; j<stride ; j++){
            data[i+j] = Y[j];
            Y[j] += Z[j];
            Z[j] += deriv2;
        }
#else
        // loops that are easy(?) for a compiler to roll up into some SIMD vectors
        for (int j=0 ; j<stride ; j++)  data[i+j] = Y[j];  // store
        for (int j=0 ; j<stride ; j++)  Y[j] += Z[j];      // add
        for (int j=0 ; j<stride ; j++)  Z[j] += deriv2;    // add
#endif
    }

    // cleanup for the last few i values
    for (int j = 0 ; j < LEN % stride ; j++) {
        // letting the compiler see the %stride can help it decide *not* to auto-vectorize this part for non-huge strides.
        size_t i = LEN - (stride-1) + j;
        //data[i] = poly(i);
    }
}

( Explorateur de compilateur Godbolt ) En fait, cela permet d'auto-vectoriser joliment avec stride=16 (4x YMM vecteurs de 4 double s chacun) avec clang14 -O3 -march=skylake -ffast-math .

Il semble que clang ait encore déroulé par 2 (mais sans inventer de nouveaux accumulateurs, bien sûr). Donc chaque itération de la boucle asm 2x 8 vaddpd instructions et 2x 4 magasins. Et un add/jne macro-fusion de l'overhead de la boucle, puisque clang compte vers zéro, en indexant depuis la fin du tableau. Donc, avec les hits de cache L1d pour les magasins, cela devrait fonctionner (sur Skylake) à 1 magasin par cycle d'horloge, faisant également 2x vaddpd chaque cycle. C'est le débit maximum pour ces deux choses. Le frontal n'a besoin de suivre qu'un peu plus de 3 uops / cycle d'horloge, mais il est large de 4 depuis Core2, et le cache uop de la famille Sandybridge fait que cela ne pose aucun problème. (Sauf si vous vous heurtez à l'erratum JCC sur Skylake, alors j'ai utilisé -mbranches-within-32B-boundaries d'avoir des instructions clang pad pour éviter cela .)

Je ne l'ai pas vraiment testé sur mon bureau. vaddpd La latence est de 4 cycles, donc 4 chaînes de dépannage sont tout juste suffisantes pour maintenir en vol 4 opérations indépendantes. Et avec deux chaînes dep imbriquées, il est probablement facile pour un ordonnancement imparfait d'entraîner des cycles perdus. Donc un peu plus de déroulage serait approprié si nous pouvions obtenir du compilateur qu'il fasse encore un asm sympa pour stride=32 .

(Je ne suis pas sûr de savoir pourquoi -ffast-math est nécessaire pour que clang puisse vectoriser décemment avec cette version ; je ne pense pas que clang ré-associe quoi que ce soit, il fait juste toutes les opérations mathématiques dans l'ordre de la source, à moins qu'il y ait quelque chose qui m'échappe ; je n'ai pas regardé de près les numéros de registre. Le source a été soigneusement écrit d'une manière SIMD-friendly pour rendre possible la compilation vers un asm sympa. Mise à jour : une autre version qui prend un pointeur comme argument permet de vectoriser avec ou sans -ffast-math .)

L'autre façon d'écrire les boucles est avec une boucle interne au lieu de toutes les opérations Y, puis toutes les opérations Z. clang n'auto-vectorise pas ceci, cependant, même avec -ffast-math seulement le style SIMD ci-dessus. Je l'ai inclus dans un #if 0 / #else / #endif bloc sur Godbolt. Mise à jour : dans la version prenant un pointeur arg, ceci vectorise mieux dans certains cas.

    for(int i=0; i < LEN - (stride-1); i+=stride) {
        for (int j = 0 ; j<stride ; j++){  // doesn't auto-vectorize with clang or GCC
            data[i+j] = Y[j];
            Y[j] += Z[j];
            Z[j] += deriv2;
        }
    }

Nous devons choisir manuellement un montant de déroulement approprié. . Un facteur d'unroll trop important peut même empêcher le compilateur de voir ce qui se passe et de garder les tableaux temp dans les registres. par exemple 32 est un problème pour clang, mais pas 24 . Il peut y avoir des options de réglage pour forcer le compilateur à dérouler les boucles jusqu'à un certain nombre ; il en existe pour GCC qui peuvent parfois être utilisées pour lui permettre de voir quelque chose au moment de la compilation.

(Une autre approche serait la vectorisation manuelle avec #include <immintrin.h> y __m256d Z[4] au lieu de double Z[16] . Mais cette version peut vectoriser pour d'autres ISA comme AArch64).

D'autres inconvénients d'un facteur de déroulement important sont de laisser plus de travail de nettoyage lorsque la taille du problème n'est pas un multiple du déroulement. (Vous pouvez utiliser le compute1 pour le nettoyage, laissant le compilateur vectoriser cela pendant une itération ou deux avant de faire du scalaire).


En théorie, un compilateur devrait être autorisé de le faire pour vous avec -ffast-math soit de compute1 en effectuant la réduction de force sur le polynôme d'origine, ou de compute2 voir comment la foulée s'accumule. Mais en pratique, c'est très compliqué et les humains doivent le faire eux-mêmes. A moins que/jusqu'à ce que quelqu'un apprenne aux compilateurs comment rechercher des modèles comme celui-ci et appliquer le calcul différentiel (ou les différences successives avec un choix de stride).


Résultats expérimentaux :

Sur mon ordinateur de bureau (i7-6700k) avec clang13.0.0, j'ai testé et j'ai été agréablement surpris de constater qu'il fonctionne en fait à un stockage SIMD par cycle d'horloge avec la combinaison d'options de compilation (fast-math ou non) et de #if 0 vs. #if 1 sur la stratégie de la boucle intérieure, avec une version du cadre de référence de @huseyin tugrul buyukisik améliorée pour répéter une quantité plus mesurable entre rdtsc des instructions. Et pour compenser la différence entre la fréquence de l'horloge centrale et celle de l'horloge de l'ordinateur. fréquence "de référence" de l'horloge rdtsc lit Dans mon cas, 3.9GHz contre 4008 MHz. (Le turbo max nominal est de 4.2GHz, mais avec EPP = 1,5GHz). balance_performance sous Linux, il ne veut tourner que jusqu'à 3,9 GHz).

Code source que j'ai testé sur Godbolt : utiliser une seule boucle interne, plutôt que 3 boucles séparées. j<16 les boucles, et pas en utilisant -ffast-math . Et en utilisant __attribute__((noinline)) pour éviter qu'il ne s'insère dans la boucle de répétition. D'autres variations des options et de la source ont conduit à quelques vpermpd se déplace à l'intérieur de la boucle.

$ clang++ -std=gnu++17 -O3 -march=native -mbranches-within-32B-boundaries poly-eval.cpp -Wall
# warning about noipa, only GCC knows that attribute
$ perf stat --all-user -etask-clock,context-switches,cpu-migrations,page-faults,cycles,instructions,uops_issued.any,uops_executed.thread,fp_arith_inst_retired.256b_packed_double -r10 ./a.out
... (10 runs of the whole program, ending with)
0.252072 cycles per data element (corrected from ref cycles to core clocks)
0.252236 cycles per data element (corrected from ref cycles to core clocks)
0.252408 cycles per data element (corrected from ref cycles to core clocks)
0.251945 cycles per data element (corrected from ref cycles to core clocks)
0.252442 cycles per data element (corrected from ref cycles to core clocks)
0.252295 cycles per data element (corrected from ref cycles to core clocks)
0.252109 cycles per data element (corrected from ref cycles to core clocks)
xor=4303
min cycles per data = 0.251868

 Performance counter stats for './a.out' (10 runs):

            298.92 msec task-clock                #    0.989 CPUs utilized            ( +-  0.49% )
                 0      context-switches          #    0.000 /sec                   
                 0      cpu-migrations            #    0.000 /sec                   
               129      page-faults               #  427.583 /sec                     ( +-  0.56% )
     1,162,430,637      cycles                    #    3.853 GHz                      ( +-  0.49% )  # time spent in the kernel for system calls and interrupts isn't counted, that's why it's not 3.90 GHz
     3,772,516,605      instructions              #    3.22  insn per cycle           ( +-  0.00% )
     3,683,072,459      uops_issued.any           #   12.208 G/sec                    ( +-  0.00% )
     4,824,064,881      uops_executed.thread      #   15.990 G/sec                    ( +-  0.00% )
     2,304,000,000      fp_arith_inst_retired.256b_packed_double #    7.637 G/sec                  

           0.30210 +- 0.00152 seconds time elapsed  ( +-  0.50% )

fp_arith_inst_retired.256b_packed_double compte 1 pour chaque instruction FP add ou mul (2 pour FMA), donc nous obtenons 1,98 vaddpd instructions par cycle d'horloge pour l'ensemble du programme, y compris l'impression et ainsi de suite. C'est très proche du maximum théorique de 2/clock, ne souffrant apparemment pas d'un ordonnancement sous-optimal des instructions d'addition. (J'ai augmenté la boucle de répétition pour qu'elle y passe plus de temps, il est donc plus utile d'utiliser perf stat sur l'ensemble du programme).

Le but de cette optimisation était d'effectuer le même travail avec moins de FLOPS, mais cela signifie également que nous maximisons essentiellement la limite de 8 FLOPS/clock pour Skylake sans utiliser la FMA. (Et nous obtenons 30,58 GFLOP/s à 3,9 GHz sur un seul cœur).

Asm de la fonction non-inline ( objdump -drwC -Mintel ) ; clang a utilisé 4 ensembles de 2 paires de vecteurs YMM, et a déroulé la boucle 3 fois de plus. Notez la add rax,0x30 faire 3 * 0x10 doubles par itération. Cela donne donc un multiple exact de 24KiB sans nettoyage.

0000000000001440 <void compute2<3072>(double*)>:
# just loading constants; the setup loop did flatten out and optimize into vector constants
    1440:       c5 fd 28 0d 18 0c 00 00         vmovapd ymm1,YMMWORD PTR [rip+0xc18]        # 2060 <_IO_stdin_used+0x60>
    1448:       c5 fd 28 15 30 0c 00 00         vmovapd ymm2,YMMWORD PTR [rip+0xc30]        # 2080 <_IO_stdin_used+0x80>
    1450:       c5 fd 28 1d 48 0c 00 00         vmovapd ymm3,YMMWORD PTR [rip+0xc48]        # 20a0 <_IO_stdin_used+0xa0>
    1458:       c4 e2 7d 19 25 bf 0b 00 00      vbroadcastsd ymm4,QWORD PTR [rip+0xbbf]        # 2020 <_IO_stdin_used+0x20>
    1461:       c5 fd 28 2d 57 0c 00 00         vmovapd ymm5,YMMWORD PTR [rip+0xc57]        # 20c0 <_IO_stdin_used+0xc0>
    1469:       48 c7 c0 d0 ff ff ff    mov    rax,0xffffffffffffffd0
    1470:       c4 e2 7d 19 05 af 0b 00 00      vbroadcastsd ymm0,QWORD PTR [rip+0xbaf]        # 2028 <_IO_stdin_used+0x28>
    1479:       c5 fd 28 f4             vmovapd ymm6,ymm4
    147d:       c5 fd 28 fc             vmovapd ymm7,ymm4
    1481:       c5 7d 28 c4             vmovapd ymm8,ymm4
    1485:       66 66 2e 0f 1f 84 00 00 00 00 00        data16 cs nop WORD PTR [rax+rax*1+0x0]

# top of outer loop.  The NOP before this is to align it.
    1490:       c5 fd 11 ac c7 80 01 00 00      vmovupd YMMWORD PTR [rdi+rax*8+0x180],ymm5
    1499:       c5 d5 58 ec             vaddpd ymm5,ymm5,ymm4
    149d:       c5 dd 58 e0             vaddpd ymm4,ymm4,ymm0
    14a1:       c5 fd 11 9c c7 a0 01 00 00      vmovupd YMMWORD PTR [rdi+rax*8+0x1a0],ymm3
    14aa:       c5 e5 58 de             vaddpd ymm3,ymm3,ymm6
    14ae:       c5 cd 58 f0             vaddpd ymm6,ymm6,ymm0
    14b2:       c5 fd 11 94 c7 c0 01 00 00      vmovupd YMMWORD PTR [rdi+rax*8+0x1c0],ymm2
    14bb:       c5 ed 58 d7             vaddpd ymm2,ymm2,ymm7
    14bf:       c5 c5 58 f8             vaddpd ymm7,ymm7,ymm0
    14c3:       c5 fd 11 8c c7 e0 01 00 00      vmovupd YMMWORD PTR [rdi+rax*8+0x1e0],ymm1
    14cc:       c5 bd 58 c9             vaddpd ymm1,ymm8,ymm1
    14d0:       c5 3d 58 c0             vaddpd ymm8,ymm8,ymm0
    14d4:       c5 fd 11 ac c7 00 02 00 00      vmovupd YMMWORD PTR [rdi+rax*8+0x200],ymm5
    14dd:       c5 d5 58 ec             vaddpd ymm5,ymm5,ymm4
    14e1:       c5 dd 58 e0             vaddpd ymm4,ymm4,ymm0
    14e5:       c5 fd 11 9c c7 20 02 00 00      vmovupd YMMWORD PTR [rdi+rax*8+0x220],ymm3
    14ee:       c5 e5 58 de             vaddpd ymm3,ymm3,ymm6
    14f2:       c5 cd 58 f0             vaddpd ymm6,ymm6,ymm0
    14f6:       c5 fd 11 94 c7 40 02 00 00      vmovupd YMMWORD PTR [rdi+rax*8+0x240],ymm2
    14ff:       c5 ed 58 d7             vaddpd ymm2,ymm2,ymm7
    1503:       c5 c5 58 f8             vaddpd ymm7,ymm7,ymm0
    1507:       c5 fd 11 8c c7 60 02 00 00      vmovupd YMMWORD PTR [rdi+rax*8+0x260],ymm1
    1510:       c5 bd 58 c9             vaddpd ymm1,ymm8,ymm1
    1514:       c5 3d 58 c0             vaddpd ymm8,ymm8,ymm0
    1518:       c5 fd 11 ac c7 80 02 00 00      vmovupd YMMWORD PTR [rdi+rax*8+0x280],ymm5
    1521:       c5 d5 58 ec             vaddpd ymm5,ymm5,ymm4
    1525:       c5 dd 58 e0             vaddpd ymm4,ymm4,ymm0
    1529:       c5 fd 11 9c c7 a0 02 00 00      vmovupd YMMWORD PTR [rdi+rax*8+0x2a0],ymm3
    1532:       c5 e5 58 de             vaddpd ymm3,ymm3,ymm6
    1536:       c5 cd 58 f0             vaddpd ymm6,ymm6,ymm0
    153a:       c5 fd 11 94 c7 c0 02 00 00      vmovupd YMMWORD PTR [rdi+rax*8+0x2c0],ymm2
    1543:       c5 ed 58 d7             vaddpd ymm2,ymm2,ymm7
    1547:       c5 c5 58 f8             vaddpd ymm7,ymm7,ymm0
    154b:       c5 fd 11 8c c7 e0 02 00 00      vmovupd YMMWORD PTR [rdi+rax*8+0x2e0],ymm1
    1554:       c5 bd 58 c9             vaddpd ymm1,ymm8,ymm1
    1558:       c5 3d 58 c0             vaddpd ymm8,ymm8,ymm0
    155c:       48 83 c0 30             add    rax,0x30
    1560:       48 3d c1 0b 00 00       cmp    rax,0xbc1
    1566:       0f 82 24 ff ff ff       jb     1490 <void compute2<3072>(double*)+0x50>
    156c:       c5 f8 77                vzeroupper 
    156f:       c3                      ret    

En rapport :

12voto

gnasher729 Points 5011

Si vous avez besoin que ce code s'exécute rapidement, ou si vous êtes curieux, vous pouvez essayer ce qui suit :

Vous avez modifié le calcul de a[i] = f(i) en deux additions. Modifiez ceci pour calculer a[4i] = f(4i) en utilisant deux additions, a[4i+1] = f(4i+1) en utilisant deux additions, et ainsi de suite. Vous avez maintenant quatre calculs qui peuvent être effectués en parallèle.

Il y a de fortes chances que le compilateur effectue le même déroulage de boucle et la même vectorisation, et que vous ayez la même latence, mais pour quatre opérations et non une.

10voto

En utilisant uniquement les additions comme optimisation, vous manquez tous les gflops des pipelines de multiplication (des CPU plus récents) et la dépendance de la boucle portée aggrave la situation en arrêtant l'auto-vectorisation. S'il était auto-vectorisé, il serait beaucoup plus rapide que la multiplication+addition. Et beaucoup plus économe en énergie par donnée (seule l'addition est meilleure que mul+add).

Un autre problème est que la fin du tableau reçoit plus d'erreur d'arrondi en raison du nombre d'additions accumulées. Mais cela ne devrait pas être visible avant les très grands tableaux (à moins que le type de données ne devienne float).

Lorsque vous appliquez le schéma Horner avec les options de construction de GCC (sur les processeurs plus récents) -std=c++20 -O3 -march=native -mavx2 -mprefer-vector-width=256 -ftree-vectorize -fno-math-errno ,

void f(double * const __restrict__ data){
    double A=1.1,B=2.2,C=3.3;
    for(int i=0; i<1024; i++) {
        double id = double(i);
        double result =  A;
        result *=id;
        result +=B;
        result *=id;
        result += C;
        data[i] = result;
    }
}

le compilateur produit ceci :

.L2:
    vmovdqa ymm0, ymm2
    vcvtdq2pd       ymm1, xmm0
    vextracti128    xmm0, ymm0, 0x1
    vmovapd ymm7, ymm1
    vcvtdq2pd       ymm0, xmm0
    vmovapd ymm6, ymm0
    vfmadd132pd     ymm7, ymm4, ymm5
    vfmadd132pd     ymm6, ymm4, ymm5
    add     rdi, 64
    vpaddd  ymm2, ymm2, ymm8
    vfmadd132pd     ymm1, ymm3, ymm7
    vfmadd132pd     ymm0, ymm3, ymm6
    vmovupd YMMWORD PTR [rdi-64], ymm1
    vmovupd YMMWORD PTR [rdi-32], ymm0
    cmp     rax, rdi
    jne     .L2
    vzeroupper
    ret

et avec -mavx512f -mprefer-vector-width=512 :

.L2:
    vmovdqa32       zmm0, zmm3
    vcvtdq2pd       zmm4, ymm0
    vextracti32x8   ymm0, zmm0, 0x1
    vcvtdq2pd       zmm0, ymm0
    vmovapd zmm2, zmm4
    vmovapd zmm1, zmm0
    vfmadd132pd     zmm2, zmm6, zmm7
    vfmadd132pd     zmm1, zmm6, zmm7
    sub     rdi, -128
    vpaddd  zmm3, zmm3, zmm8
    vfmadd132pd     zmm2, zmm5, zmm4
    vfmadd132pd     zmm0, zmm5, zmm1
    vmovupd ZMMWORD PTR [rdi-128], zmm2
    vmovupd ZMMWORD PTR [rdi-64], zmm0
    cmp     rax, rdi
    jne     .L2
    vzeroupper
    ret

toutes les opérations FP sont sous forme vectorielle "emballée" et nécessitent moins d'instructions (il s'agit d'une version deux fois déroulée) en raison de la réunion de mul+add en un seul FMA. 16 instructions pour 64 octets de données (128 octets si AVX512).

Un autre avantage du schéma de Horner est qu'il calcule avec une meilleure précision dans le cadre de l'instruction FMA et qu'il n'effectue que O(1) opérations par itération de boucle, ce qui fait qu'il n'accumule pas beaucoup d'erreurs avec des tableaux plus longs.

Je pense que l'optimisation des manuels d'optimisation d'Agner Fog doit venir de l'époque de l'approximation rapide de la racine carrée inverse de Quake-3. À cette époque, SIMD n'était pas assez large pour faire une grande différence, et il n'y avait pas de support pour la fonction sqrt. Le manuel indique un copyright de 2004, donc il y avait des Celerons avec SSE et non FMA. Le premier processeur de bureau AVX a été lancé bien plus tard et la FMA était encore plus tardive que cela.


Voici une autre version avec une réduction de la force (pour la valeur de l'id) :

void f(double * const __restrict__ data){

    double B[]={2.2,2.2,2.2,2.2,2.2,2.2,2.2,2.2,
    2.2,2.2,2.2,2.2,2.2,2.2,2.2,2.2};
    double C[]={3.3,3.3,3.3,3.3,3.3,3.3,3.3,3.3,
    3.3,3.3,3.3,3.3,3.3,3.3,3.3,3.3};
    double id[] = {0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15};
    for(long long i=0; i<1024; i+=16) {
        double result[]={1.1,1.1,1.1,1.1,1.1,1.1,1.1,1.1,
                        1.1,1.1,1.1,1.1,1.1,1.1,1.1,1.1};
        // same thing, just with explicit auto-vectorization help
        for(int j=0;j<16;j++)
        {
            result[j] *=id[j];
            result[j] +=B[j];
            result[j] *=id[j];
            result[j] += C[j];
            data[i+j] = result[j];
        }

        // strength reduction
        for(int j=0;j<16;j++)
        {
            id[j] += 16.0;
        }
    }
}

montage :

.L2:
    vmovapd zmm3, zmm0
    vmovapd zmm2, zmm1
    sub     rax, -128
    vfmadd132pd     zmm3, zmm6, zmm7
    vfmadd132pd     zmm2, zmm6, zmm7
    vfmadd132pd     zmm3, zmm5, zmm0
    vfmadd132pd     zmm2, zmm5, zmm1
    vaddpd  zmm0, zmm0, zmm4
    vaddpd  zmm1, zmm1, zmm4
    vmovupd ZMMWORD PTR [rax-128], zmm3
    vmovupd ZMMWORD PTR [rax-64], zmm2
    cmp     rdx, rax
    jne     .L2
    vzeroupper
    ret

Lorsque les données, les tableaux A, B et C sont alignés par alignas(64) et une taille de tableau de données suffisamment petite, il fonctionne à 0.26 cycles par élément vitesse.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X