É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.
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 [~ # ~]
__m128
_ et ___m128d
_ Cette réponse (voir ci-dessous)__m256d
_ avec analyse de performances pour Ryzen 1 vs Intel (montrant pourquoi _vextractf128
_ est largement meilleur que _vperm2f128
_) Obtenez la somme des valeurs stockées dans __m256d avec SSE/AVX__m256
_ Comment additionner __m256 horizontalement?Entier
__m128i
_ Éléments 32 bits: cette réponse (voir ci-dessous). Les éléments 64 bits devraient être évidents: une seule étape pshufd/paddq.___m128i
_ Éléments non signés 8 bits: psadbw
contre _mm_setzero_si128()
, puis hsum les deux moitiés qword (ou 4 ou 8 pour vecteurs plus larges). Méthode la plus rapide pour additionner horizontalement SSE vecteur d'octets non signé affiche 128 bits avec SSE2. Addition des entiers 8 bits dans __m512i avec AVX intrinsèques a un exemple AVX512. Comment compter les occurrences de caractères à l'aide de SIMD a un exemple AVX2 ___m256i
_.
(Pour les octets signés, vous pouvez XOR set1 (0x80) pour passer à non signé avant SAD, puis soustraire le biais de la somme finale).
_mm_madd_epi16
_ avec set1 (1) en tant que bloc de construction horizontal à élargissement unique pour les entiers étroits: SIMD: Accumuler les paires adjacentes___m256i
_ et ___m512i
_ avec des éléments 32 bits. Méthode la plus rapide pour calculer la somme de tous les entiers 32 bits compressés à l'aide d'AVX512 ou AVX2 . Pour AVX512, Intel a ajouté un tas de fonctions en ligne "réduire" (pas des instructions matérielles) qui le font pour vous, comme __mm512_reduce_add_ps
_ (et pd, epi32 et epi64). Également réduire_min/max/mul/et/ou. Le faire manuellement conduit essentiellement au même asm.
horizontal max (au lieu d'ajouter): Obtenir la valeur max dans un vecteur __m128i avec SSE?
__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:
haddps
, c'est donc très pertinent ici.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
}
_
_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.
_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.
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 .
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 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.
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).
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));
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).
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.
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).