web-dev-qa-db-fra.com

Moyen le plus rapide de faire horizontal SSE somme vectorielle (ou autre réduction)

Étant donné un vecteur de trois (ou quatre) flottants. Quel est le moyen le plus rapide de les additionner?

SSE (movaps, shuffle, add, movd) est-il toujours plus rapide que x87? Les instructions d'ajout horizontal dans SSE3 en valent-elles la peine?

Quel est le coût pour passer au FPU, puis faddp, faddp? Quelle est la séquence d'instructions spécifique la plus rapide?

"Essayez d'arranger les choses pour pouvoir additionner quatre vecteurs à la fois" ne sera pas accepté comme réponse. :-) par exemple. pour additionner un tableau, vous pouvez utiliser plusieurs accumulateurs vectoriels pour des sommes verticales (pour masquer la latence des addps) et réduire à un après la boucle, mais vous devez ensuite additionner horizontalement ce dernier vecteur.

42
FeepingCreature

En général, pour tout type de réduction horizontale de vecteur, extraire/mélanger de haut en bas, puis ajouter verticalement (ou min/max/ou/et/xor/multiplier/peu importe); répétez jusqu'à ce qu'il ne reste qu'un seul élément. Si vous commencez avec des vecteurs plus larges que 128 bits, rétrécissez-les en deux jusqu'à ce que vous arriviez à 128 (alors vous pouvez utiliser l'une des fonctions de cette réponse vecteur). À moins que vous n'ayez besoin que le résultat soit diffusé à tous les éléments à la fin, vous pouvez envisager de faire des shuffles pleine largeur tout le long.

Questions et réponses connexes pour les vecteurs plus larges et les entiers: [~ # ~] fp [~ # ~]

Entier


Réponse principale à ceci question: principalement float et ___m128_

Voici quelques versions optimisées basées sur le guide des microarchives d'Agner Fog et les tables d'instructions. Voir également le wiki x86 tag. Ils devraient être efficaces sur n'importe quel processeur, sans goulots d'étranglement majeurs. (Par exemple, j'ai évité des choses qui pourraient aider un peu un uarque mais qui seraient lentes pour un autre uarque). La taille du code est également minimisée.

L'idiome SSE3/SSSE3 2x hadd commun n'est valable que pour la taille du code, pas pour la vitesse sur les processeurs existants. Il existe des cas d'utilisation (comme transposer et ajouter, voir ci-dessous), mais un seul vecteur n'en fait pas partie.

J'ai également inclus une version AVX. Tout type de réduction horizontale avec AVX/AVX2 doit commencer par un _vextractf128_ et une opération "verticale" pour réduire à un vecteur XMM (___m128_). En général, pour les vecteurs larges, votre meilleur pari est de réduire de moitié à plusieurs reprises jusqu'à ce que vous soyez réduit à un vecteur 128 bits, quel que soit le type d'élément. (Sauf pour un entier de 8 bits, puis vpsadbw comme première étape si vous voulez hsum sans déborder vers des éléments plus larges.)

Voir la sortie asm de tout ce code sur Godbolt Compiler Explorer . Voir aussi mes améliorations de Fonctions de la bibliothèque de classes vectorielles C++ d'Agner Fog _horizontal_add_. ( fil de discussion , et code sur github ). J'ai utilisé des macros CPP pour sélectionner des shuffles optimaux pour la taille du code pour SSE2, SSE4 et AVX, et pour éviter movdqa lorsque AVX n'est pas disponible.


Il y a des compromis à considérer:

  • taille du code: plus petit est meilleur pour des raisons de cache I L1 et pour la récupération de code à partir du disque (binaires plus petits). La taille binaire totale importe principalement pour les décisions du compilateur prises à plusieurs reprises dans un programme. Si vous prenez la peine de coder à la main quelque chose avec des éléments intrinsèques, cela vaut la peine de dépenser quelques octets de code si cela donne une accélération pour l'ensemble du programme (faites attention aux microbenchmarks qui font que le déroulement semble bon).
  • taille uop-cache: souvent plus précieuse que L1 I $. 4 instructions single-uop peuvent prendre moins d'espace que 2 haddps, c'est donc très pertinent ici.
  • latence: parfois pertinente
  • débit (ports principaux): généralement non pertinents, les sommes horizontales ne devraient pas être la seule chose dans une boucle la plus interne. La pression du port n'a d'importance que dans le cadre de la boucle entière qui contient cela.
  • throughput (total front-end fused-domain uops): si le code environnant ne gêne pas sur le même port que l'hsum utilise, il s'agit d'un proxy pour l'impact de l'hsum sur le débit de l'ensemble.

Lorsqu'un ajout horizontal est peu fréquent :

Les processeurs sans uop-cache pourraient favoriser 2x haddps s'il est très rarement utilisé: il est lent lorsqu'il s'exécute, mais ce n'est pas souvent . Être seulement 2 instructions minimise l'impact sur le code environnant (taille I $).

Les processeurs avec uop-cache favoriseront probablement quelque chose qui prend moins d'uops, même si c'est plus d'instructions/plus de taille de code x86. Le total des lignes de cache uops utilisées est ce que nous voulons minimiser, ce qui n'est pas aussi simple que de minimiser le total des uops (les branches prises et les limites 32B commencent toujours une nouvelle ligne de cache uop).

Quoi qu'il en soit, cela étant dit, les sommes horizontales montent un beaucoup, alors voici ma tentative de créer soigneusement des versions qui se compilent bien. Non comparé à aucun matériel réel, ni même soigneusement testé. Il pourrait y avoir des bogues dans les constantes de lecture aléatoire ou quelque chose.


Si vous créez une version de secours/de base de votre code, n'oubliez pas que seuls les anciens processeurs l'exécuteront ; les CPU plus récents exécuteront votre version AVX, ou SSE4.1 ou autre.

Les anciens processeurs comme K8 et Core2 (merom) et les versions antérieures n'ont que des unités de mélange 64 bits . Core2 possède des unités d'exécution 128 bits pour la plupart des instructions, mais pas pour les shuffles. (Pentium M et K8 gèrent toutes les instructions vectorielles 128b comme deux moitiés 64 bits).

Les shuffles comme movhlps qui déplacent les données en morceaux 64 bits (pas de shuffling dans les moitiés 64 bits) sont également rapides.

Connexe: mélange sur les nouveaux processeurs et astuces pour éviter le goulot d'étranglement du débit aléatoire 1/horloge sur Haswell et versions ultérieures: Les opérations sur 128 voies en AVX512 offrent-elles de meilleures performances?

Sur les anciens processeurs avec des shuffles lents :

  • movhlps (Merom: 1uop) est nettement plus rapide que shufps (Merom: 3uops). Sur Pentium-M, moins cher que movaps. En outre, il s'exécute dans le domaine FP sur Core2, évitant les retards de contournement des autres shuffles.
  • unpcklpd est plus rapide que unpcklps.
  • pshufd est lent, pshuflw/pshufhw sont rapides (car ils ne mélangent qu'une moitié 64 bits)
  • _pshufb mm0_ (MMX) est rapide, _pshufb xmm0_ est lent.
  • haddps est très lent (6uops sur Merom et Pentium M)
  • movshdup (Merom: 1uop) est intéressant : C'est le seul insu 1uop qui mélange dans les éléments 64b.

shufps sur Core2 (y compris Penryn) apporte des données dans le domaine entier, provoquant un délai de contournement pour les récupérer dans les unités d'exécution FP pour addps, mais movhlps est entièrement dans le domaine FP. shufpd s'exécute également dans le domaine flottant.

movshdup s'exécute dans le domaine entier, mais n'est qu'un uop.

AMD K10, Intel Core2 (Penryn/Wolfdale) et tous les processeurs ultérieurs exécutent tous les shuffles xmm comme une seule uop. (Mais notez le délai de contournement avec shufps sur Penryn, évité avec movhlps)


Sans AVX, éviter les instructions gaspillées movaps/movdqa nécessite un choix judicieux des shuffles . Seuls quelques shuffles fonctionnent comme une copie et un shuffle, plutôt que de modifier la destination. Les shuffles qui combinent les données de deux entrées (comme _unpck*_ ou movhlps) peuvent être utilisés avec une variable tmp qui n'est plus nécessaire à la place de _mm_movehl_ps(same,same).

Certains d'entre eux peuvent être rendus plus rapides (enregistrer un MOVAPS) mais plus/moins "propres" en prenant un argument factice à utiliser comme destination pour un shuffle initial. Par exemple:

_// Use dummy = a recently-dead variable that vec depends on,
//  so it doesn't introduce a false dependency,
//  and the compiler probably still has it in a register
__m128d highhalf_pd(__m128d dummy, __m128d vec) {
#ifdef __AVX__
    // With 3-operand AVX instructions, don't create an extra dependency on something we don't need anymore.
    (void)dummy;
    return _mm_unpackhi_pd(vec, vec);
#else
    // Without AVX, we can save a MOVAPS with MOVHLPS into a dead register
    __m128 tmp = _mm_castpd_ps(dummy);
    __m128d high = _mm_castps_pd(_mm_movehl_ps(tmp, _mm_castpd_ps(vec)));
    return high;
#endif
}
_

SSE1 (alias SSE):

_float hsum_ps_sse1(__m128 v) {                                  // v = [ D C | B A ]
    __m128 shuf   = _mm_shuffle_ps(v, v, _MM_SHUFFLE(2, 3, 0, 1));  // [ C D | A B ]
    __m128 sums   = _mm_add_ps(v, shuf);      // sums = [ D+C C+D | B+A A+B ]
    shuf          = _mm_movehl_ps(shuf, sums);      //  [   C   D | D+C C+D ]  // let the compiler avoid a mov by reusing shuf
    sums          = _mm_add_ss(sums, shuf);
    return    _mm_cvtss_f32(sums);
}
    # gcc 5.3 -O3:  looks optimal
    movaps  xmm1, xmm0     # I think one movaps is unavoidable, unless we have a 2nd register with known-safe floats in the upper 2 elements
    shufps  xmm1, xmm0, 177
    addps   xmm0, xmm1
    movhlps xmm1, xmm0     # note the reuse of shuf, avoiding a movaps
    addss   xmm0, xmm1

    # clang 3.7.1 -O3:  
    movaps  xmm1, xmm0
    shufps  xmm1, xmm1, 177
    addps   xmm1, xmm0
    movaps  xmm0, xmm1
    shufpd  xmm0, xmm0, 1
    addss   xmm0, xmm1
_

J'ai signalé un bug de clang sur la pessimisation des shuffles . Il a sa propre représentation interne pour le brassage et le transforme en shuffles. gcc utilise plus souvent les instructions qui correspondent directement à l'intrinsèque que vous avez utilisé.

Souvent, clang fait mieux que gcc, dans le code où le choix de l'instruction n'est pas réglé manuellement, ou la propagation constante peut simplifier les choses même lorsque les intrinsèques sont optimales pour le cas non constant. Dans l'ensemble, c'est une bonne chose que les compilateurs fonctionnent comme un bon compilateur pour les intrinsèques, pas seulement un assembleur. Les compilateurs peuvent souvent générer un bon asm à partir du scalaire C qui n'essaye même pas de fonctionner comme le ferait un bon asm. Finalement, les compilateurs traiteront l'intrinsèque comme un simple opérateur C en entrée pour l'optimiseur.


SSE3

_float hsum_ps_sse3(__m128 v) {
    __m128 shuf = _mm_movehdup_ps(v);        // broadcast elements 3,1 to 2,0
    __m128 sums = _mm_add_ps(v, shuf);
    shuf        = _mm_movehl_ps(shuf, sums); // high half -> low half
    sums        = _mm_add_ss(sums, shuf);
    return        _mm_cvtss_f32(sums);
}

    # gcc 5.3 -O3: perfectly optimal code
    movshdup    xmm1, xmm0
    addps       xmm0, xmm1
    movhlps     xmm1, xmm0
    addss       xmm0, xmm1
_

Cela présente plusieurs avantages:

  • ne nécessite aucune copie de movaps pour contourner les shuffles destructeurs (sans AVX): la destination de _movshdup xmm1, xmm2_ est en écriture seule, donc elle crée tmp à partir d'un registre mort pour nous. C'est aussi pourquoi j'ai utilisé movehl_ps(tmp, sums) au lieu de movehl_ps(sums, sums).

  • petite taille de code. Les instructions de mélange sont petites: movhlps est de 3 octets, movshdup est de 4 octets (comme shufps). Aucun octet immédiat n'est requis, donc avec AVX, vshufps est de 5 octets mais vmovhlps et vmovshdup sont tous les deux 4.

Je pourrais enregistrer un autre octet avec addps au lieu de addss. Comme cela ne sera pas utilisé à l'intérieur des boucles internes, l'énergie supplémentaire pour commuter les transistors supplémentaires est probablement négligeable. FP les exceptions des 3 éléments supérieurs ne sont pas un risque, car tous les éléments contiennent des données valides FP. Cependant, clang/LLVM "comprend" réellement les shuffles vectoriels) , et émet un meilleur code s'il sait que seul l'élément bas compte.

Comme pour la version SSE1, l'ajout des éléments impairs à eux-mêmes peut provoquer FP exceptions (comme un débordement) qui ne se produiraient pas autrement, mais cela ne devrait pas poser de problème. Les dénormals sont lents, mais IIRC produire un résultat + Inf n'est pas sur la plupart des uarches.


SSE3 optimisation pour la taille du code

Si la taille du code est votre principale préoccupation, deux instructions haddps (__mm_hadd_ps_) feront l'affaire (réponse de Paul R). C'est aussi le plus facile à taper et à mémoriser. Ce n'est pas rapide , cependant. Même Intel Skylake décode toujours chaque haddps en 3 uops, avec une latence de 6 cycles. Ainsi, même s'il enregistre des octets de code machine (cache I L1), il occupe plus d'espace dans le cache uop le plus précieux. Cas d'utilisation réels pour haddps: un problème de transposition et de somme , ou faire une mise à l'échelle à une étape intermédiaire dans this = SSE atoi() implémentation .


AVX:

Cette version enregistre un octet de code contre la réponse de Marat à la question AVX .

_#ifdef __AVX__
float hsum256_ps_avx(__m256 v) {
    __m128 vlow  = _mm256_castps256_ps128(v);
    __m128 vhigh = _mm256_extractf128_ps(v, 1); // high 128
           vlow  = _mm_add_ps(vlow, vhigh);     // add the low 128
    return hsum_ps_sse3(vlow);         // and inline the sse3 version, which is optimal for AVX
    // (no wasted instructions, and all of them are the 4B minimum)
}
#endif

 vmovaps xmm1,xmm0               # huh, what the heck gcc?  Just extract to xmm1
 vextractf128 xmm0,ymm0,0x1
 vaddps xmm0,xmm1,xmm0
 vmovshdup xmm1,xmm0
 vaddps xmm0,xmm1,xmm0
 vmovhlps xmm1,xmm1,xmm0
 vaddss xmm0,xmm0,xmm1
 vzeroupper 
 ret
_

Double precision:

_double hsum_pd_sse2(__m128d vd) {                      // v = [ B | A ]
    __m128 undef  = _mm_undefined_ps();                       // don't worry, we only use addSD, never touching the garbage bits with an FP add
    __m128 shuftmp= _mm_movehl_ps(undef, _mm_castpd_ps(vd));  // there is no movhlpd
    __m128d shuf  = _mm_castps_pd(shuftmp);
    return  _mm_cvtsd_f64(_mm_add_sd(vd, shuf));
}

# gcc 5.3.0 -O3
    pxor    xmm1, xmm1          # hopefully when inlined, gcc could pick a register it knew wouldn't cause a false dep problem, and avoid the zeroing
    movhlps xmm1, xmm0
    addsd   xmm0, xmm1


# clang 3.7.1 -O3 again doesn't use movhlps:
    xorpd   xmm2, xmm2          # with  #define _mm_undefined_ps _mm_setzero_ps
    movapd  xmm1, xmm0
    unpckhpd        xmm1, xmm2
    addsd   xmm1, xmm0
    movapd  xmm0, xmm1    # another clang bug: wrong choice of operand order


// This doesn't compile the way it's written
double hsum_pd_scalar_sse2(__m128d vd) {
    double tmp;
    _mm_storeh_pd(&tmp, vd);       // store the high half
    double lo = _mm_cvtsd_f64(vd); // cast the low half
    return lo+tmp;
}

    # gcc 5.3 -O3
    haddpd  xmm0, xmm0   # Lower latency but less throughput than storing to memory

    # ICC13
    movhpd    QWORD PTR [-8+rsp], xmm0    # only needs the store port, not the shuffle unit
    addsd     xmm0, QWORD PTR [-8+rsp]
_

Le stockage dans la mémoire et le dos évite un uop ALU. C'est bien si la pression du port de lecture aléatoire, ou les ALU uops en général, sont un goulot d'étranglement. (Notez qu'il n'a pas besoin de _sub rsp, 8_ ou quoi que ce soit car l'ABI SysV x86-64 fournit une zone rouge sur laquelle les gestionnaires de signaux ne marcheront pas.)

Certaines personnes stockent dans un tableau et additionnent tous les éléments, mais les compilateurs ne réalisent généralement pas que l'élément bas du tableau est toujours là dans un registre d'avant le magasin.


Entier:

pshufd est une fonction pratique de copie et de lecture aléatoire. Les décalages de bits et d'octets sont malheureusement en place, et punpckhqdq place la moitié haute de la destination dans la moitié basse du résultat, à l'opposé de la façon dont movhlps peut extraire la moitié haute dans un autre S'inscrire.

L'utilisation de movhlps pour la première étape peut être bonne sur certains processeurs, mais seulement si nous avons un scratch reg. pshufd est un choix sûr et rapide sur tout après Merom.

_int hsum_epi32_sse2(__m128i x) {
#ifdef __AVX__
    __m128i hi64  = _mm_unpackhi_epi64(x, x);           // 3-operand non-destructive AVX lets us save a byte without needing a mov
#else
    __m128i hi64  = _mm_shuffle_epi32(x, _MM_SHUFFLE(1, 0, 3, 2));
#endif
    __m128i sum64 = _mm_add_epi32(hi64, x);
    __m128i hi32  = _mm_shufflelo_epi16(sum64, _MM_SHUFFLE(1, 0, 3, 2));    // Swap the low two elements
    __m128i sum32 = _mm_add_epi32(sum64, hi32);
    return _mm_cvtsi128_si32(sum32);       // SSE2 movd
    //return _mm_extract_epi32(hl, 0);     // SSE4, even though it compiles to movd instead of a literal pextrd r32,xmm,0
}

    # gcc 5.3 -O3
    pshufd xmm1,xmm0,0x4e
    paddd  xmm0,xmm1
    pshuflw xmm1,xmm0,0x4e
    paddd  xmm0,xmm1
    movd   eax,xmm0

int hsum_epi32_ssse3_slow_smallcode(__m128i x){
    x = _mm_hadd_epi32(x, x);
    x = _mm_hadd_epi32(x, x);
    return _mm_cvtsi128_si32(x);
}
_

Sur certains processeurs, il est sûr d'utiliser FP mélange les données entières. Je ne l'ai pas fait, car sur les processeurs modernes qui enregistreront au plus 1 ou 2 octets de code, sans gain de vitesse ( autres que la taille du code/les effets d'alignement).

71
Peter Cordes

SSE2

Tout les quatres:

const __m128 t = _mm_add_ps(v, _mm_movehl_ps(v, v));
const __m128 sum = _mm_add_ss(t, _mm_shuffle_ps(t, t, 1));

r1 + r2 + r3:

const __m128 t1 = _mm_movehl_ps(v, v);
const __m128 t2 = _mm_add_ps(v, t1);
const __m128 sum = _mm_add_ss(t1, _mm_shuffle_ps(t2, t2, 1));

J'ai trouvé que c'était à peu près la même vitesse que le double HADDPS (mais je n'ai pas mesuré trop près).

18
Kornel

Vous pouvez le faire dans deux instructions HADDPS dans SSE3:

v = _mm_hadd_ps(v, v);
v = _mm_hadd_ps(v, v);

Cela met la somme dans tous les éléments.

10
Paul R

Je voudrais certainement essayer SSE 4.2. Si vous faites cela plusieurs fois (je suppose que vous l'êtes si les performances sont un problème), vous pouvez pré-charger un registre avec (1,1, 1,1), puis faites plusieurs dot4 (my_vec (s), one_vec) dessus. Oui, il fait une multiplication superflue, mais ceux-ci sont assez bon marché de nos jours et un tel op est susceptible d'être dominé par les dépendances horizontales, qui peut être plus optimisé dans la nouvelle fonction de produit scalaire SSE. Vous devriez tester pour voir si elle surpasse la double addition horizontale publiée par Paul R).

Je suggère également de le comparer au code scalaire droit (ou scalaire SSE) - étrangement, il est souvent plus rapide (généralement parce qu'il est sérialisé en interne mais étroitement pipeliné en utilisant le contournement de registre, où les instructions horizontales spéciales peuvent ne pas être accélérées (encore)) sauf si vous exécutez du code de type SIMT, ce qui semble ne pas être le cas (sinon vous feriez quatre produits scalaires).

4
Crowley9