11
votes

Est-il possible de vectoriser mynum + = a [b [i]] * c [i]; sur x86_64?

Quelle intrigue utiliserais-je pour vectoriser ce qui suit (si elle est même possible de vectoriser) sur le X86_64?

double myNum = 0;
for(int i=0;i<n;i++){
    myNum += a[b[i]] * c[i]; //b[i] = int, a[b[i]] = double, c[i] = double
}


2 commentaires

Quelle est la répartition des indices en B?


Juste curieux, les suggestions ci-dessous ont-elles aidé à accélérer votre code?


5 Réponses :


0
votes

réponse courte no. Longue réponse oui, mais pas efficacement. Vous encourrez la pénalité pour faire des charges non alignées qui annuleront tout type d'avantage. À moins que vous ne puissiez garantir que B [i] des indices successifs sont alignés, vous aurez probablement pire des performances après la vectorisation

Si vous connaissez à l'avance, quels indices sont, votre meilleur qui consiste à dérouler et à spécifier des indices explicites. J'ai fait quelque chose de similaire à l'aide de la spécialisation et de la génération de codes. Si vous êtes intéressé, je peux partager

pour répondre à votre commentaire, vous devez essentiellement vous concentrer sur un tableau. La chose la plus facile à essayer de t'appuier est de vous bloquer en boucle d'un facteur de deux, de charge faible et élevé A séparément, puis utilisez mm _ pd comme habituellement. Pseudocode: xxx

Je ne me souviens pas de noms de fonction exactement, peut vouloir vérifier. Utilisez également le mot-clé restreint avec les pointeurs si vous savez qu'il ne peut y avoir de problèmes d'aliasing. Cela permettra au compilateur d'être beaucoup plus agressif.


3 commentaires

Pouvez-vous expliquer comment j'aurais voûter cela (même avec la pénalité non alignée)? Je serais curieux de comparer moi-même les performances.


Cela ne va pas fonctionner, en raison de la double indirection des indices.


Je ne pense pas que la restriction n'aura aucun avantage ici, car toutes les écritures sont à une variable locale. S'ils étaient informatisés d [i] = a [b [i]] * C [i], puis limitez-le sur D aiderait.



0
votes

Cela ne va pas vectorialiser tel qu'il est, en raison de la double indirection des indices de réseau. Puisque vous travaillez avec des doubles, il y a peu ou rien à gagner de SSE, en particulier comme la plupart des processeurs modernes ont de 2 FPU de toute façon.


8 commentaires

Mauvais, SSE2 permet de travailler simultanément avec deux doubles 64 bits dans un registre SSE de 128 bits.


@Liranuna - Comment le traitement de deux doubles dans un registre SSE vous donne-t-il un avantage sur deux FPU? En effet, les frais généraux supplémentaires de l'emballage de deux doubles non contigus dans un registre SSE rendront presque certainement une solution SSE plus lent qu'une implémentation scalaire.


@Paul: SSE n'est pas un linceul d'optimisation magique. Si vous y abusez mal, vous le trouverez plus lentement que le code naïf. Une utilisation appropriée de SSE, cependant, vous donnera toujours de la vitesse d'au moins 30%.


@Liranuna - Je sais - je plaidais contre SSE dans ce cas.


@Paul, pourquoi ne pas utiliser d'autres fonctionnalités déjà utilisées? x86_64 Le FPU est déjà cohérent de Mulsd et addsd , pourquoi ne pas utiliser la même instruction (qui coûte la même quantité de cycles) pour faire doubler le travail? Consultez ma réponse pour faire appel à la meilleure utilisation de SSE (2) pour ce cas.


@Liranuna - Vous pouvez voir un petit avantage, mais je doute que ce soit n'importe où près de 2x pour ce cas, où le nombre d'opérations arithmétiques est faible et que vous avez beaucoup de charges par itération. Vous avez également une charge mal alignée là-bas, ce qui est coûteux sur tout sauf un noyau i7. Cela mériterait de comparer la comparée et comparer le débit avec une implémentation scalaire simple.


Oui, mais le _mm_loadu_pd est le seul véritable goulot d'étranglement, le reste n'est presque rien comparé à celui-ci. J'ai encouragé à aligner C et essayer de conserver n même, mais si cela est impossible, alors vous êtes correct, ce code peut fonctionner avec un avantage minimal. Il est toujours plus rapide que l'original, qui utilise trop de addpd s et mulpd 's.


@Liranuna Si vous êtes rond pour analyser ce code, je serais intéressé de voir les résultats. Si vous publiez votre code de référence, je vais le construire et l'exécuter avec ICC sur Core 2 et Core I7.



4
votes

Je commencerais par dérouler la boucle. Quelque chose comme xxx

, espérons-le, qui permet au compilateur d'interlacer les charges avec l'arithmétique; Profil et regarder l'assemblée pour voir s'il y a une amélioration. Idéalement, le compilateur générera des instructions SSE, mais je ne suis pas si cela se produit dans la pratique.

déroulant peut vous laisser faire cela: xxx

( Toutes mes excuses pour le pseudocode au début et à la fin, je pense que la partie importante était la boucle). Je ne sais pas à coup sûr si cela sera plus rapide; Cela dépend des différentes latences et de la façon dont le compilateur peut tout réorganiser. Assurez-vous de profil avant et après voir s'il y avait une amélioration réelle.

espère que cela aide.


1 commentaires

En outre, cela pourrait ne pas être utile pour le moment, mais je crois que l'architecture à venir d'Intel, Larrabee, aura des instructions de rassemblement / dispersion pour faire face à ce type de cas. Voir drdobbs.com/architect/216402188?pgno=4 pour des informations sur ce.



8
votes

Voici mon allez-y, entièrement optimisé et testé:

#include <emmintrin.h>

__m128d sum = _mm_setzero_pd();
for(int i=0; i<n; i+=2) {
    sum = _mm_add_pd(sum, _mm_mul_pd(
        _mm_loadu_pd(c + i),
        _mm_setr_pd(a[b[i]], a[b[i+1]])
    ));
}

if(n & 1) {
    sum = _mm_add_pd(sum, _mm_set_sd(a[b[n-1]] * c[n-1]));
}

double finalSum = _mm_cvtsd_f64(_mm_add_pd(
    sum, _mm_shuffle_pd(sum, sum, _MM_SHUFFLE2(0, 1))
));


4 commentaires

Nice, j'ai oublié de charger directement.


Oh, hé, wow - je ne voulais pas arracher la réponse sélectionnée de @celion ... c'était mon amusement personnel ...


Je suis sûr que je vais finir finalement :) Je pense qu'une combinaison de nos deux réponses serait optimale - mon déroulement de la boucle à nouveau et votre chargement 'c' via intrinsèque.


Habituellement, vous devriez faire votre nettoyage scalaire avec pure scalaire, comme finalsum = 0; ou finalsum = A [B [N-1]] * C [N-1]; en parallèle avec la somme horizontale.



2
votes

Les processeurs Intel peuvent émettre deux opérations de points flottants, mais une charge par cycle, de sorte que les accès à la mémoire sont la contrainte la plus serrée. Dans cet esprit, j'ai d'abord visé à utiliser des charges emballées pour réduire le nombre d'instructions de charge et utiliser des arithmétiques emballés simplement parce que c'était pratique. Depuis depuis que je me suis rendu compte que la bande passante de la mémoire saturation peut être la plus grosse problème et que tous les messages avec les instructions SSE auraient pu être une optimisation prématurée si le point était de rendre le code rapide plutôt que d'apprendre à vous vectoriser.

SSE

Le moins de charges possibles sans hypothèse sur les indices dans B nécessite déroulant la boucle quatre fois. Une charge de 128 bits obtient quatre indices de B , deux charges de 128 bits reçoivent une paire de doubles adjacents à partir de C et de collecte a requis indépendants Charges 64 bits. C'est un étage de 7 cycles pour quatre itérations pour le code de série. (Assez pour saturer ma largeur de bande de mémoire si l'accès à A ne cache pas bien). J'ai laissé de côté des choses ennuyeuses comme la manipulation d'un certain nombre d'itérations qui ne sont pas un multiple de 4. xxx

obtenir les indices de sortie est la partie la plus compliquée. MOVDQA Charges 128 bits de données entier à partir d'une adresse alignée de 16 octets (NEHALEM a des pénalités de latence pour mélanger les instructions "entier" et "Flott" SSE). PunPCKHQDQ Déplace High 64 bits à BIX 64 bits, mais en iteuse, contrairement au plus simplement nommé movhlpd . Les changements 32 bits sont effectués dans les registres à usage général. MOVHPD Charge un double dans la partie supérieure d'un registre XMM sans déranger la partie inférieure - Ceci est utilisé pour charger les éléments de A directement dans des registres emballés.

Ce code distinctement plus rapide que le code ci-dessus, qui est à son tour plus rapide que le code simple, et sur chaque modèle d'accès, mais le cas simple b [i] = i où la boucle naïve est réellement le plus rapide. J'ai aussi essayé quelques choses comme une fonction autour de somme (A (B (:)), c (:)) dans Fortran qui s'est finalement équivalent à la simple boucle.

J'ai testé sur un Q6600 (65 nm noyau 2 à 2,4 GHz) avec 4 Go de mémoire DDR2-667, en 4 modules. Test de la bande passante de la mémoire donne environ 5333 mb / s, il semble donc que je ne vois qu'un seul canal. Je compile avec le GCC de Debian 4.3.2-1.1, -O3 -FFAST-MATH -SSE2 -FTREE -FTREE-Vectorize -Std = gnu99.

Pour tester Je laisse n Soyez un million, initialiser les tableaux de sorte que a [b [i]] et c [i] égal 1.0 / (i + 1) , avec quelques modèles différents d'indices. On attribue A avec un million d'éléments et définies b à une permutation aléatoire, une autre allouate a avec des éléments de 10 m et utilise tous les 10, et le dernier Allocates A avec des éléments 10M et définit B [i + 1] en ajoutant un nombre aléatoire de 1 à 9 à B [i] . Je time à jour combien de temps un appel prend avec GetTimeoDay , effacer les caches en appelant clflush sur les tableaux et mesurant 1000 essais de chaque fonction. J'ai tracé des distributions d'exécution lissées en utilisant du code de la courgette de critère (en particulier, l'estimateur de densité de noyau dans le package ).

bande passante

maintenant, pour la note importante sur la bande passante. 5333 Mo / s avec une horloge de 2,4 GHz comporte un peu plus de deux octets par cycle. Mes données sont suffisamment longues que rien ne devrait être cachéable et multiplier le temps d'exécution de ma boucle (16 + 2 * 16 + 4 * 64) octets chargé par itération Si tout ce que tout manque, me donne presque exactement la bande passante ~ 53333Mb / s Mon système a . Il devrait être assez facile de saturer cette bande passante sans SSE. Même en supposant que a a été complètement mis en cache, il suffit de lire B et c pour une itéération déplace 12 octets de données et que la naïf peut commencer une nouvelle itération. Toujours troisième cycle avec pipelining.

supposer que quelque chose de moins que la mise en cache complète sur A rend l'arithmétique et l'instruction comptent encore moins d'un goulot d'étranglement. Je ne serais pas surpris si la majeure partie de l'accélération de mon code vient de délivrer moins de charges à B et c aussi plus d'espace est libre de suivre et de spéculer au-dessus de la cache de cache. a .

Le matériel plus large peut faire plus de différence. Un système NEHALEM qui exécute trois canaux de DDR3-1333 aurait besoin de déplacer 3 * 10667 / 2.66 = 12,6 octets par cycle pour la cuve de bande de mémoire saturée. Ce serait impossible pour un seul thread si a s'adapte au cache - mais à 64 octets, une ligne cache manque sur le vecteur addition rapide - une des quatre charges de ma boucle manquantes dans les caches monte la bande passante moyenne requise à 16 octets / cycle.


2 commentaires

Sandybridge et plus tard peut faire 2 charges par horloge. Le noyau 2 / Nehalem est également différent des CPU d'Intel ultérieurs dans la section 3 / Horloge (0.33C Débit) pour MOWD / Q REG, XMM . Les CPU ultérieurs (à partir de HASWELL à ~ 2013) ont seulement un débit de 1 / horloge pour cela, alors le goulot d'étranglement. agner.org/optimize/instrucance_tables.pdf (vous le voudriez donc 64 -bez des charges scalaires pour décompresser avec MOV / SHR ). Mais oui, c'est probablement bon pour Core2 / Nehalem.


[RCX + 8 * R8 + 8] - qui devrait être +16 , sinon il est mal aligné et se chevauche le précédent MOVAPD .