Vectorisation (informatique)
La vectorisation (dans le cadre du calcul parallÚle), est un cas particulier de la parallélisation, dans lequel des logiciels qui effectuent par défaut une seule opération à la fois sur un seul thread sont modifiés pour effectuer plusieurs opérations simultanément.
La vectorisation est le processus de conversion d'un programme informatique à partir d'une implémentation scalaire, qui traite une seule paire d'opérandes à la fois, à une implémentation vectorielle qui traite une opération sur plusieurs paires d'opérandes à la fois. Le terme vient de la convention de mettre les opérandes dans des vecteurs ou des matrices.
Le calcul vectoriel est une caractéristique majeure concernant à la fois les ordinateurs classiques et les superordinateurs modernes, pouvant réaliser des opérations vectorielles qui effectuent simultanément des opérations telles que par exemple les quatre additions suivantes :
Cependant, dans la plupart des langages de programmation on écrit généralement des boucles qui effectuent séquentiellement des additions de grands nombres. Voici un exemple d'une telle boucle, en C :
for (i=0; i<n; i++)
c[i] = a[i] + b[i];
La vectorisation automatique est un sujet de recherche majeur en informatique ; cela consiste à rechercher des méthodes qui permettent à un compilateur de convertir (sans assistance humaine) des programmes scalaires en programmes vectorisés.
Contexte
Les premiers ordinateurs avaient gĂ©nĂ©ralement une unitĂ© logique qui exĂ©cutait sĂ©quentiellement une instruction sur une paire d'opĂ©randes Ă la fois. Les programmes informatiques et les langages de programmation ont donc Ă©tĂ© conçus pour exĂ©cuter des instructions de façon sĂ©quentielle. Les ordinateurs modernes peuvent faire beaucoup de choses Ă la fois. Un grand nombre de compilateurs optimisants rĂ©alisent une vectorisation automatique du code : c'est une fonctionnalitĂ© du compilateur qui permet Ă certaines parties des programmes sĂ©quentiels d'ĂȘtre transformĂ©s en programmes parallĂšles Ă©quivalents afin de produire du code qui sera bien utilisĂ© par un processeur vectoriel.
Garanties
La vectorisation automatique, tout comme l'optimisation de boucle ou une quelconque autre optimisation de compilation, doit préserver exactement le comportement du programme.
Dépendances de données
Toutes les dĂ©pendances doivent ĂȘtre respectĂ©es lors de l'exĂ©cution afin d'Ă©viter des rĂ©sultats incorrects.
En gĂ©nĂ©ral, les invariants de boucle et les dĂ©pendances Ă portĂ©es lexicale peuvent ĂȘtre facilement vectorisĂ©es et les dĂ©pendances qui ne sont pas Ă portĂ©es peuvent l'ĂȘtre Ă©galement, bien que plus difficilement. Mais ces transformations doivent ĂȘtre effectuĂ©es en toute sĂ©curitĂ©, afin d'assurer la dĂ©pendance entre toutes les dĂ©clarations en restant fidĂšle au code original.
Les dĂ©pendances cycliques doivent ĂȘtre traitĂ©es indĂ©pendamment des instructions vectorisĂ©es.
Précision des données
La prĂ©cision d'entier (taille binaire) doit ĂȘtre maintenue pendant l'exĂ©cution de l'instruction vectorielle. Cette derniĂšre doit ĂȘtre choisie correctement en fonction de la taille et du comportement des entiers internes. En outre, avec les types d'entiers mixtes, des prĂ©cautions supplĂ©mentaires doivent ĂȘtre prises pour promouvoir/rĂ©trograder correctement sans perte de prĂ©cision. Un soin particulier doit ĂȘtre pris avec extension de signe (plusieurs entiers Ă©tant emballĂ©s dans le mĂȘme registre) et pendant les opĂ©rations de transformation, ou des opĂ©rations de carry bit qui auraient Ă©tĂ© pris en compte.
La prĂ©cision en virgule flottante doit aussi ĂȘtre conservĂ©e, Ă moins que la conformitĂ© Ă la norme IEEE-754 ne soit pas en vigueur, auquel cas les opĂ©rations seront plus rapides mais les rĂ©sultats varier lĂ©gĂšrement. De grandes variations, ignorant mĂȘme IEEE-754 signifient gĂ©nĂ©ralement des erreurs de programmation. Le programmeur peut aussi forcer les constantes et les variables de boucle en simple prĂ©cision (par dĂ©faut deux normalement) pour exĂ©cuter deux fois plus d'opĂ©rations par instruction.
Théorie
Pour vectoriser un programme, l'optimiseur du compilateur doit d'abord comprendre les dépendances entre les déclarations et les réaligner si nécessaire. Une fois les dépendances mappées, l'optimiseur doit organiser correctement les implémentations d'instructions des candidats appropriés aux vecteurs instructions qui fonctionnent sur plusieurs éléments de données.
Construire le graphe de dépendances
La premiĂšre Ă©tape est de construire le graphe de dĂ©pendances, en identifiant les dĂ©clarations dĂ©pendant des autres dĂ©clarations. Il s'agit d'examiner chaque dĂ©claration et l'identification en autre de chaque Ă©lĂ©ment de donnĂ©es que la dĂ©claration accĂšde. L'analyse d'alias peut ĂȘtre utilisĂ©e pour certifier que les diffĂ©rentes variables accĂšdent Ă la mĂȘme allocation mĂ©moire.
Le graphe de dĂ©pendance contient toutes les dĂ©pendances locales avec une distance infĂ©rieure Ă la taille du vecteur. Donc, si le registre vectoriel est de 128 bits, et le type de tableau est de 32 bits, la taille du vecteur est 128/32 = 4. Toutes les autres dĂ©pendances non-cycliques ne devraient pas invalider la vectorisation, car il n'y aura pas un accĂšs simultanĂ© Ă la mĂȘme instruction vectorielle.
Supposons que la taille du vecteur est le mĂȘme que 4 entiers (ints) :
for (i = 0; i < 128; i++) {
a[i] = a[i-16]; // 16 > 4, ignoré
a[i] = a[i-1]; // 1 < 4, reste sur le graphe de dépendances
}
Clustering
à l'aide du graphe, l'optimiseur peut alors regrouper les composantes fortement connexes (SCC) et les déclarations vectorisables séparées du reste.
Par exemple, considĂ©rons un fragment de programme contenant trois clusters d'instructions Ă l'intĂ©rieur d'une boucle : (SCC1 + SCC2), SCC3 et SCC4, dans cet ordre, dans lequel seul le deuxiĂšme cluster (SCC3) peut ĂȘtre vectorisĂ©. Le programme dĂ©finitif contiendra trois boucles, une pour chaque cluster, avec seulement celui du milieu vectorisĂ©. L'optimiseur ne peut pas rejoindre la premiĂšre Ă la derniĂšre sans violer l'ordre d'exĂ©cution de l'instruction, qui invaliderait les garanties nĂ©cessaires.
DĂ©tection des idiomes
Certaines dĂ©pendances non-Ă©videntes peuvent ĂȘtre optimisĂ©es davantage en se basant sur des expressions idiomatiques spĂ©cifiques.
Par exemple, les auto-dĂ©pendances de donnĂ©es suivantes peuvent ĂȘtre vectorisĂ©es du fait que la valeur des valeurs de droite (RHS) sont rĂ©cupĂ©rĂ©es, puis stockĂ©es sur la valeur de gauche, de sorte qu'il est impossible que les donnĂ©es changent au sein de l'assignation.
a[i] = a[i] + a[i+1];
L'auto-dĂ©pendance par scalaires peut ĂȘtre vectorisĂ©e par l'Ă©limination de variables.
Cadre général
Le cadre général de la vectorisation de boucles est divisé en quatre étapes :
- PrĂ©lude : lorsque les variables de boucle indĂ©pendantes sont prĂȘtes Ă ĂȘtre utilisĂ©es Ă l'intĂ©rieur de la boucle. Ceci implique normalement de les dĂ©placer dans des registres vectoriels avec des motifs spĂ©cifiques qui seront utilisĂ©s dans des instructions vectorielles. C'est aussi l'endroit pour insĂ©rer le contrĂŽle de dĂ©pendance d'exĂ©cution. Si le contrĂŽle dĂ©cide que la vectorisation n'est pas possible, passage Ă l'Ă©tape de nettoyage.
- Boucle(s) : toutes les boucles vectorisées (ou non), séparées par clusters CSSC par ordre d'apparition dans le code original.
- Postlude : retourne toutes les variables de boucles indépendantes, inductions et réductions.
- Nettoyage : implémente tout simplement les boucles (non-vectorisées) pour les itérations à l'extrémité d'une boucle qui ne sont pas un multiple de la taille de vecteur ou pour quand les contrÎles d'exécution interdisent le traitement de vecteur.
Runtime vs compilation
Certaines vectorisations ne peuvent pas ĂȘtre entiĂšrement vĂ©rifiĂ©es au moment de la compilation. L'optimisation au moment de la compilation exige un index explicite dâarray. Les fonctions de bibliothĂšque peuvent Ă©galement dĂ©faire l'optimisation si les donnĂ©es qu'elles traitent sont fournies par des paramĂštres externes. MĂȘme dans ces cas, l'optimisation d'exĂ©cution peut encore vectoriser des boucles en fonctionnement.
Ce contrÎle d'exécution est fait dans l'étape de prélude et dirige le flux vers des instructions vectorisées si possible, autrement on revient au traitement standard, selon les variables qui sont passées sur les registres ou les variables scalaires.
Le code suivant peut facilement ĂȘtre vectorisĂ© au moment de la compilation, car il ne possĂšde pas de fonctions faisant appel ou dĂ©pendant de paramĂštres externes. En outre, le langage garantit qu'il n'occupera la mĂȘme allocation mĂ©moire que toute autre variable, Ă©tant des variables locales et ne vivant que dans la pile d'exĂ©cution.
int a[128];
int b[128];
// initialise b
for (i = 0; i<128; i++)
a[i] = b[i] + 5;
D'autre part, le code ci-dessous n'a pas d'information sur les positions de mémoire car les références sont des pointeurs et la mémoire dans laquelle elles sont allouées est dynamique.
int *a = malloc(128*sizeof(int));
int *b = malloc(128*sizeof(int));
// initialise b
for (i = 0; i<128; i++, a++, b++)
*a = *b + 5;
// ...
// ...
// ...
free(b);
free(a);
Une vérification rapide d'exécution sur l'adresse de a et b, ainsi que l'espace boucle d'itération (128) est suffisant pour dire si les arrays se chevauchent ou non, révélant ainsi toutes les dépendances.
Il existe des outils pour analyser dynamiquement les applications existantes afin d'évaluer le potentiel latent inhérent à parallélisme SIMD, exploitable grùce à l'évolution du compilateur et/ou par des changements de code manuels[1].
Techniques
Un exemple serait un programme multipliant deux vecteurs de données numériques. Une approche scalaire serait quelque chose comme :
for (i = 0; i < 1024; i++)
C[i] = A[i]*B[i];
Il pourrait ĂȘtre vectorisĂ© pour ressembler Ă ceci :
for (i = 0; i < 1024; i+=4)
C[i:i+3] = A[i:i+3]*B[i:i+3];
Ici, C [i: i + 3] reprĂ©sente les quatre indices de tableau de C[i] Ă C[i + 3] et le processeur vectoriel peut effectuer quatre opĂ©rations pour une seule instruction vectorielle. Du fait que les quatre opĂ©rations vectorielles sont complĂ©tĂ©es en mĂȘme temps Ă peu prĂšs qu'une instruction scalaire, l'approche par vecteur peut exĂ©cuter jusqu'Ă quatre fois plus rapide que le code original.
Il existe deux approches distinctes de compilation : l'une basée sur la technique de vectorisation classique et l'autre sur la base de déroulage de boucle.
Vectorisation automatique au niveau boucle
Cette technique, utilisée pour les machines à vecteurs classiques, essaie de trouver et d'exploiter le parallélisme SIMD au niveau de la boucle. Il se compose de deux grandes étapes, comme suit.
- Trouver une boucle interne qui peut ĂȘtre vectorisĂ©e ;
- Transformer la boucle et générer des codes vectoriels ;
Dans la premiĂšre Ă©tape, le compilateur recherche les obstacles qui peuvent empĂȘcher vectorisation. Un obstacle majeur pour la vectorisation est une vraie dĂ©pendance de donnĂ©es plus courte que la longueur du vecteur. D'autres obstacles incluent les appels de fonction et les courts nombre d'itĂ©rations.
Une fois que la boucle est déterminée comme étant vectorisable, la boucle est strip-minée par la longueur du vecteur et chaque instruction scalaire à l'intérieur du corps de la boucle est remplacée par l'instruction vectorielle correspondante. Ci-dessous, les transformations des composants de cette étape sont présentés en utilisant l'exemple ci-dessus.
- AprĂšs strip-mining
for (i = 0; i < 1024; i+=4)
for (ii = 0; ii < 4; ii++)
C[i+ii] = A[i+ii]*B[i+ii];
- AprĂšs la distribution de boucles Ă l'aide de tableaux temporaires
for (i = 0; i < 1024; i+=4)
{
for (ii = 0; ii < 4; ii++) tA[ii] = A[i+ii];
for (ii = 0; ii < 4; ii++) tB[ii] = B[i+ii];
for (ii = 0; ii < 4; ii++) tC[ii] = tA[ii]*tB[ii];
for (ii = 0; ii < 4; ii++) C[i+ii] = tC[ii];
}
- AprĂšs le remplacement Ă l'aide de codes vectoriels
for (i = 0; i < 1024; i+=4)
{
vA = vec_ld( &A[i] );
vB = vec_ld( &B[i] );
vC = vec_mul( vA, vB );
vec_st( vC, &C[i] );
}
Vectorisation automatique au niveau bloc de base
Cette technique relativement nouvelle cible spĂ©cifiquement les architectures SIMD modernes avec de courtes longueurs de vecteur[2]. Bien que les boucles peuvent ĂȘtre dĂ©roulĂ©es pour augmenter la quantitĂ© de parallĂ©lisme SIMD en blocs de base, cette technique exploite le parallĂ©lisme SIMD Ă l'intĂ©rieur des blocs de base plutĂŽt que des boucles. Les deux principales Ă©tapes sont les suivantes :
- La boucle la plus interne est déroulée par un facteur de la longueur du vecteur pour former un grand corps de boucle.
- Les instructions scalaires isomorphes (qui effectuent la mĂȘme opĂ©ration) sont empaquetĂ©es dans une instruction vectorielle si les dĂ©pendances ne l'empĂȘchent pas de le faire.
Pour afficher les transformations Ă©tape-par-Ă©tape de cette approche, le mĂȘme exemple que ci-dessus est utilisĂ© Ă nouveau.
- AprĂšs dĂ©roulage des boucles (par la longueur du vecteur, supposĂ©e ĂȘtre 4 dans ce cas)
for (i = 0; i < 1024; i+=4)
{
sA0 = ld( &A[i+0] );
sB0 = ld( &B[i+0] );
sC0 = sA0 * sB0;
st( sC0, &C[i+0] );
...
sA3 = ld( &A[i+3] );
sB3 = ld( &B[i+3] );
sC3 = sA3 * sB3;
st( sC3, &C[i+3] );
}
- AprĂšs l'empaquetage
for (i = 0; i < 1024; i+=4)
{
(sA0,sA1,sA2,sA3) = ld( &A[i+0:i+3] );
(sB0,sB1,sB2,sB3) = ld( &B[i+0:i+3] );
(sC0,sC1,sC2,sC3) = (sA0,sA1,sA2,sA3) * (sB0,sB1,sB2,sB3);
st( (sC0,sC1,sC2,sC3), &C[i+0:i+3] );
}
- AprÚs la génération du code
for (i = 0; i < 1024; i+=4)
{
vA = vec_ld( &A[i] );
vB = vec_ld( &B[i] );
vC = vec_mul( vA, vB );
vec_st( vC, &C[i] );
}
Ici, sA1, SB1, ... représentent des variables scalaires et VA, VB, et Vc représentent des variables vectorielles.
Les compilateurs commerciaux de vectorisation automatique emploient pour la plupart l'approche au niveau de la boucle conventionnelle excepté le compilateur d'IBM XL[3], qui emploient chacun des deux.
En présence de flux de contrÎle
La prĂ©sence de if dans le corps de la boucle nĂ©cessite l'exĂ©cution d'instructions dans tous les chemins de commande pour fusionner les multiples valeurs d'une variable. Une approche gĂ©nĂ©rale est de passer par une sĂ©quence de transformations de code : PrĂ©dication â vectorisation (en utilisant l'une des mĂ©thodes ci-dessus) â retirer les prĂ©dicats des vecteurs â supprimer les prĂ©dicats scalaires.
Le code suivant est utilisé comme un exemple pour montrer ces transformations[4] :
for (i = 0; i < 1024; i++)
if (A[i] > 0)
C[i] = B[i];
else
D[i] = D[i-1];
- AprÚs la prédication
for (i = 0; i < 1024; i++)
{
P = A[i] > 0;
NP = !P;
C[i] = B[i]; (P)
D[i] = D[i-1]; (NP)
}
oĂč (P) dĂ©signe un prĂ©dicat gardant la dĂ©claration.
- AprĂšs la vectorisation
for (i = 0; i < 1024; i+=4)
{
vP = A[i:i+3] > (0,0,0,0);
vNP = vec_not(vP);
C[i:i+3] = B[i:i+3]; (vP)
(NP1,NP2,NP3,NP4) = vNP;
D[i+3] = D[i+2]; (NP4)
D[i+2] = D[i+1]; (NP3)
D[i+1] = D[i]; (NP2)
D[i] = D[i-1]; (NP1)
}
- AprÚs la suppression des prédicats de vecteurs
for (i = 0; i < 1024; i+=4)
{
vP = A[i:i+3] > (0,0,0,0);
vNP = vec_not(vP);
C[i:i+3] = vec_sel(C[i:i+3],B[i:i+3],vP);
(NP1,NP2,NP3,NP4) = vNP;
D[i+3] = D[i+2]; (NP4)
D[i+2] = D[i+1]; (NP3)
D[i+1] = D[i]; (NP2)
D[i] = D[i-1]; (NP1)
}
- AprÚs la suppression des prédicats scalaires
for (i = 0; i < 1024; i+=4)
{
vP = A[i:i+3] > (0,0,0,0);
vNP = vec_not(vP);
C[i:i+3] = vec_sel(C[i:i+3],B[i:i+3],vP);
(NP1,NP2,NP3,NP4) = vNP;
if (NP4) D[i+3] = D[i+2];
if (NP3) D[i+2] = D[i+1];
if (NP2) D[i+1] = D[i];
if (NP1) D[i] = D[i-1];
}
Réduction du surcoût de la vectorisation en présence de flux de contrÎle
Devoir exĂ©cuter les instructions dans tous les chemins de contrĂŽle en code vectoriel a Ă©tĂ© un des principaux facteurs ralentissant le code vectoriel en ce qui concerne la base scalaire. Plus le flux de commande devient complexe et plus d'instructions sont dĂ©viĂ©s dans le code scalaire plus la vectorisation devient difficile. Pour rĂ©duire son surcoĂ»t, des branchements vectoriels peuvent ĂȘtre insĂ©rĂ©s pour contourner les vecteur d'instructions similaires Ă la façon dont les branchements scalaires contournent les instructions scalaires[5]. Ci-dessous, les prĂ©dicats AltiVec sont utilisĂ©s pour montrer comment cela peut ĂȘtre rĂ©alisĂ©.
- Base scalaire (code original)
for (i = 0; i < 1024; i++)
{
if (A[i] > 0)
{
C[i] = B[i];
if (B[i] < 0)
D[i] = E[i];
}
}
- AprÚs la vectorisation en présence de flux de contrÎle
for (i = 0; i < 1024; i+=4)
{
vPA = A[i:i+3] > (0,0,0,0);
C[i:i+3] = vec_sel(C[i:i+3],B[i:i+3],vPA);
vT = B[i:i+3] < (0,0,0,0);
vPB = vec_sel((0,0,0,0), vT, vPA);
D[i:i+3] = vec_sel(D[i:i+3],E[i:i+3],vPB);
}
- AprĂšs l'insertion dans des branchements vectoriels
for (i = 0; i < 1024; i+=4)
if (vec_any_gt(A[i:i+3],(0,0,0,0)))
{
vPA = A[i:i+3] > (0,0,0,0);
C[i:i+3] = vec_sel(C[i:i+3],B[i:i+3],vPA);
vT = B[i:i+3] < (0,0,0,0);
vPB = vec_sel((0,0,0,0), vT, vPA);
if (vec_any_ne(vPB,(0,0,0,0)))
D[i:i+3] = vec_sel(D[i:i+3],E[i:i+3],vPB);
}
Il y a deux choses à noter dans le code final avec des branchements vectoriels. D'une part, le prédicat définissant l'instruction pour vPA est également inclus dans le corps du branchement vectoriel externe en utilisant vec_any_gt. D'autre part, la rentabilité du branchement vectoriel intérieure pour vPB dépend de la probabilité conditionnelle de vPB ayant de fausses valeurs dans tous les champs étant donné que vPA a de fausses valeurs dans tous les champs de bits.
Prenons un exemple oĂč le branchement externe dans la base scalaire est toujours prise en contournant la plupart des instructions dans le corps de la boucle. Le cas intermĂ©diaire ci-dessus, sans branchements vectoriels, exĂ©cute toutes les instructions vectorielles. Le code final, avec des branchements vectoriels, exĂ©cute Ă la fois la comparaison et le branchement en mode vectoriel, gagnant potentiellement en performance par rapport Ă la base scalaire.
Voir aussi
Références
- (en) Cet article est partiellement ou en totalitĂ© issu de lâarticle de WikipĂ©dia en anglais intitulĂ© « Vectorization_(parallel_computing) » (voir la liste des auteurs).
- http://dl.acm.org/citation.cfm?id=2254108
- S. Larsen et S. Amarasinghe, « Proceedings of the ACM SIGPLAN conference on Programming language design and implementation », ACM SIGPLAN Notices, vol. 35, no 5,â , p. 145â156 (DOI 10.1145/358438.349320)
- « Code Optimization with IBM XL Compilers », (consulté en )
- J. Shin, M. W. Hall et J. Chame, « Proceedings of the international symposium on Code generation and optimization », Superword-Level Parallelism in the Presence of Control Flow,â , p. 165â175 (ISBN 0-7695-2298-X, DOI 10.1109/CGO.2005.33)
- J. Shin, « Proceedings of the 16th International Conference on Parallel Architecture and Compilation Techniques », Introducing Control Flow into Vectorized Code,â , p. 280â291 (DOI 10.1109/PACT.2007.41)