Fonctionnement d'un ordinateur/Les architectures à parallélisme de données

Nous allons maintenant aborder le parallélisme de données, qui consiste à traiter des données différentes en parallèle. De nombreuses situations s'y prêtent relativement bien : traitement d'image, manipulation de sons, vidéo, rendu 3d, etc. Mais pour exploiter ce parallélisme, il a fallu concevoir des processeurs adaptés. Les architectures les plus simples exécutent une instruction sur plusieurs données en parallèle, à l'intérieur d'un processeur. Ces architectures exploitent le parallélisme de données au niveau de l'unité de calcul, celle-ci pouvant exécuter un même calcul sur des données différentes en parallèle.

Si on omet quelques exceptions, on peut classer ces architectures dans plusieurs catégories principales : les processeurs à instructions SIMD, les processeurs à instructions SIMD à prédicat, les processeurs SIMT, les processeurs vectoriels. Si les processeurs vectoriels sont assez rares de nos jours, tous les processeurs récents incorporent des instructions SIMD. Quant au SIMT, il est utilisé sur les cartes graphiques modernes.

Les instructions SIMD : les points communs entre tous les processeurs SIMD

modifier

Les processeurs modernes fournissent presque tous des instructions SIMD, qui sont capables de traiter plusieurs éléments en parallèle. Elles travaillent sur des nombres entiers ou flottants regroupés dans ce qu'on appelle un vecteur, qui sont eux-mêmes stockés dans des registres spécialisés. En général, tous les vecteurs ont une taille fixe, peu importe leur contenu. Cela implique que suivant la taille des données à manipuler, on pourra en placer plus ou moins dans un vecteur. Par exemple, un vecteur de 128 bits pourra contenir 4 entiers de 32 bits, 4 flottants 32 bits, ou 8 entiers de 16 bits.

 
Contenu d'un vecteur en fonction du type de données utilisé.

Les vecteurs sont stockés dans des registres vectoriels, aussi appelés registres SIMD. Un registre vectoriel peut contenir un vecteur complet, pas plus. En conséquence, ils ont une taille assez importante : ils font généralement 128,,256, voire 512 bits, comparé aux 32/64 bits des registres scalaires. Le résultat est que les processeurs SIMD modernes ont des registres SIMD séparés des registres entiers/flottants normaux. Un défaut de cette organisation est que les registres vectoriels sont des registres en plus, qui doivent être sauvegardés lors des commutations de contexte, lors des interruptions, lors des appels systèmes, etc.

Comparaison entre un processeur sans registres vectoriels, et avec registres vectoriels.
 
CPU Non-SIMD
 
CPU SIMD

Les instructions SIMD peuvent être rassemblées en deux grands groupes : les horizontales et les verticales.

Les instructions SIMD verticales travaillent en parallèle sur les éléments qui sont "à la même place" dans deux vecteurs. Elles peuvent additionner ou multiplier deux vecteurs, par exemple. Pour prendre l'exemple d'une instruction d'addition vectorielle, celle-ci va additionner ensemble les données qui sont à la même place dans deux vecteurs, et placer le résultat dans un autre vecteur, à la même place.

Les instructions SIMD horizontales partent d'un vecteur et ont pour résultat un simple nombre. Elles peuvent calculer la somme ou le produit des éléments d'un vecteur, renvoyer le nombre d’éléments nuls dans un vecteur, etc.

 
Instructions SIMD

Les instructions SIMD sont difficilement utilisables dans des langages de haut niveau et c'est donc au compilateur de traduire un programme avec des instructions vectorielles. Les transformations qui permettent de traduire des morceaux de programmes en instructions vectorielles (déroulage de boucles, strip-mining) portent le nom de vectorisation.

Les instructions SIMD arithmétiques verticales

modifier

Les instructions SIMD sont essentiellement des instructions arithmétiques, comme des additions, des soustractions, des multiplications, éventuellement des opérations bit à bit, et quelques autres. Suivant la taille des données, et le type de celle-si, on devra effecteur des instructions différentes. Par exemple, on devra utiliser deux instructions d'addition différentes suivant qu'on manipule des flottants 64 bits ou des entiers 32 bits. De même, l'instruction pour additionner des vecteurs d'entiers de 16 bits sera différente de celle manipulant des vecteurs d'entiers de 32 bits.

L'addition et la multiplication peuvent générer des débordements d'entier. Pour les instructions non-SIMD, le débordement d'une addition est géré par un bit de retenue, stocké dans le registre d'état. Mais pour les instructions SIMD, ce n'est pas une solution très facile à implémenter. A la place, les additions et soustractions utilisent généralement l'arithmétique saturée, à savoir que lorsqu'une addition déborde, le résultat est la valeur maximale représentable dans un entier.

Pour la multiplication, les instructions non-SIMD génère un résultat qui est codé sur le double du nombre de bits. Une multiplication de deux nombres 64 bits donnera un résultat sur 128 bits, par exemple. Pour gérer cela, les multiplications non-SIMD stockent ce résultat dans deux registres, ou ne conservent que les 64 bits de poids faible. D'autres solutions sont possibles, mais ces deux là sont les plus utilisées. Pour les instructions SMID, une autre solution est préférée car plus simple pour de telles instructions : fournir deux instructions : une qui calcule les 64 bits de poids faible du résultat, une autre pour les 64 bits de poids fort.

Les processeurs SIMD étant utilisés pour du traitement d'image ou du rendu 2D/3D, ils supportent généralement des opérations mathématiques assez complexes sur des nombres flottants. Il n'est pas rare d'avoir des instructions SIMD pour le calcul de l'inverse d'un nombre, de sa racine carrée, l'inverse d'une racine carrée, des fonctions trigonométriques, etc. L'implémentation matérielle de telles instructions est généralement très complexe et gourmande en circuits, ce qui fait que les concepteurs de processeurs rusent. Sauf sur certains processeurs assez peu fréquents, les instructions mathématiques complexes ne calculent pas un résultat exact, mais une approximation du résultat. Utiliser un résultat approximatif ne pose pas de problème pour de telles applications, le rendu d'image ou 3D s'en accommodant parfaitement, contrairement au calcul scientifique.

Les instructions SIMD de manipulation de données intra-vecteur

modifier

Après avoir vu les instructions SIMD verticales, il est temps de voir les instructions SIMD horizontales. Pour rappel, celles-ci travaillent un vecteur unique, dont elles réarrangent ou modifient les éléments. Tout cela sera plus parlant une fois qu'on aura donné quelques exemples. Mais avant toute chose, nous allons séparer les instructions horizontales en deux sous-types, fondamentalement différents. Le premier sous-type change de place les données dans un vecteur. Le second type est celui des instructions horizontales arithmétiques/logiques habituelles, qu'on verra plus tard. La distinction entre les deux est très importante, comme on le verra plus tard.

 
Instructions SIMD de mouvement de données à une opérande.

Les instructions SIMD horizontales les plus communes bougent des données à l'intérieur des vecteurs, ce qui leur vaut le nom d'instructions de manipulation de vecteur. Les plus simples sont celles qui ont une seule opérande, à savoir qui prennent un vecteur comme opérande et renvoient un vecteur résultat.

Les plus intuitives sont les instructions de permutation, dont le nom est assez explicite. Elles changent de place des données dans un vecteur. Dans le cas le plus compliqué, elles prennent deux vecteur : le vecteur dans lequel faire la permutation, et un vecteur qui indique comment faire la permutation. Le vecteur précise, pour chaque élement du vecteur, où il doit aller après la permutation. L'instruction effectue la permutation des entiers/flottants/octets dans le vecteur.

Les instructions compress et expand sont elles aussi spécifiques aux vecteurs. L'instruction compress prend un vecteur en opérande, sélectionne certaines valeurs présentes dedans, et les stocke dans un vecteur de sortie. Elle regroupe les valeurs sélectionnées dans le début du vecteur, et laisse les cases inoccupées à 0. Par exemple, pour un vecteur de 16 entiers, elle permet de sélectionner 5 entiers dedans, et les place dans un vecteur résultat qui ne contient que ces 5 valeurs au début du vecteur, le reste est à 0. L'instruction expand fait l'inverse : elle prend un vecteur crée par l'instruction compress (ou du moin qui a le même format), prend toutes les valeurs non-nulles dedans, et les disperse dans un vecteur de sortie, en les mettant à la place désirée.

Les autres instructions SIMD horizontales prennent deux opérandes, voire aucune opérande vectorielle (elles prennent un nombre, pas un vecteur).

Les instructions shuffle prennent plusieurs vecteurs, sélectionnent les éléments adéquats dans chaque vecteur, et les regroupe dans un vecteur résultat. Par exemple, prenons 2 vecteurs de 16 entiers chacun. Une instruction shuffle peut prendre 8 entiers dans chaque vecteur et les regrouper dans un vecteur résultat unique de 16 entiers. Ou encore, elle peut prendre trois vecteurs de 16 flottants chacun, prendre 4 flottants dans le premier, 10 dans le second et 2 dans le troisième, et regrouper le tout dans un vecteur résultat de 16 flottants.

Il arrive que les deux cas précédents correspondent à des cas séparés, à deux instructions de type shuffle, mais qui fonctionnent différemment. Le premier type prend les N premiers éléments du premier vecteur, puis prend les élements manquant à partir de la fin du vecteur. Le second type prend un élément sur deux dans chaque vecteur, mais avec un décalage d'un rang entre les deux vecteurs.

Il y a aussi les instructions dites de broadcast, qui copient une valeur unique dans un vecteur entier. Elles sont surtout utilisées pour initialiser des tableaux avec une valeur unique, ou pour remplir un vecteur avec des données prédéterminées pour certains calculs impliquant des constantes.

 
Instructions SIMD de mouvement de données qui ne sont pas à une opérande.

Les instructions SIMD de réduction

modifier

Il existe des instructions horizontales de type arithmétiques. La plus simple est celle qui additionne les différents entiers/flottants d'un vecteur. Elle est utilisée pour accélérer une opération précise : faire la somme d'un tableau d'entier/flottants. On peut aussi citer les opérations maximum et minimum, qui renvoient le plus grand/petit élément d'un vecteur. Un point important avec ces opérations est qu'elles prennent un vecteur, mais renvoie un résultat unique, un scalaire, un nombre entier/flottant seul. Elles sont parfois appelées des instructions de réduction.

Les instructions de réduction sont généralement la spécificité des processeurs vectoriels, les processeurs SIMD récents n'en ont pas. La raison est que leur implémentation matérielle est généralement assez compliquée. Contrairement aux instructions verticales, elles ont une forme de parallélisme de donnée très limitée. Elles ne travaillent pas exactement sur des données indépendantes, elles font des calculs dont le résultat est utilisé par d'autres, il y a des dépendances de données entre éléments du vecteur.

Cependant, il faut savoir que l'on peut émuler une instruction de réduction en utilisant des instructions verticales couplées à des instructions de manipulation de vecteur. Par exemples, prenons le cas d'une instruction de réduction qui additionne entre eux les éléments d'un vecteur. On peut l'émuler en utilisant une addition verticale entre deux vecteurs, et une instruction compress. L'idée est la suivante : on coupe le vecteur initial en deux avec deux instructions compress, ce qui donne deux sous-vecteurs. Puis, on additionne les deux vecteurs résultat entre eux. Puis on répète les deux étapes précédentes jusqu'à obtenir la somme finale.

Le fait que l'on puisse émuler les instructions de réduction est la raison principale qui explique que les processeurs SIMD récents n’intègrent pas d'instructions de réduction. Ils se débrouillent avec des instructions SIMD de manipulation de vecteur, et des instructions SIMD verticales. C'est un bon compromis entre cout en circuits et performance.

Les accès mémoire

modifier

La gestion des accès mémoire est assez hétéroclite, la façon de faire étant différente selon les architectures. Dans le cas le plus simple, les données d'un vecteur sont contigües en mémoire et les instructions ont juste à préciser l'adresse mémoire du début du vecteur, qui est généralement dans un registre d'adresse spécialisé, ou un registre non-SIMD. Avec des vecteurs de 8 octets, toute instruction d'accès mémoire de ce type va lire ou écrire des blocs de 8 octets. L'adresse de départ de ces blocs est soumise à des contraintes d'alignement sur les jeux d'instructions comme le SSE, le MMX, etc. La raison à cela est que gérer des accès non alignés en mémoire rend les circuits de lecture/écriture en mémoire plus complexes. En contrepartie, ces contraintes compliquent l'utilisation des instructions SIMD par le compilateur.

D'autres modes d'adressage des vecteurs permettent à une instruction de charger des données dispersées en mémoire pour les rassembler dans un vecteur. On peut notamment citer l'existence d'accès mémoires en stride et en scatter-gather.

L'accès en stride regroupe des données séparées par un intervalle régulier d'adresses. Ce mode d'accès a besoin de l'adresse initiale, de celle du premier élément du vecteur, et de la distance entre deux données en mémoire. Il permet aux instructions de mieux gérer les tableaux de structures, ainsi que les tableaux multi-dimensionnels. Lorsqu'on utilise de tels tableaux, il arrive assez souvent que l'on n'accède qu'à des éléments tous séparés par une même distance. Par exemple, si on fait des calculs de géométrie dans l'espace, on peut très bien ne vouloir traiter que les coordonnées sur l'axe des x, sans accès sur l'axe des y ou des z. Les instructions d'accès mémoire en enjambées gèrent de tels cas efficacement.

Les processeurs SIMD incorporent aussi les accès en scatter-gather. De tels accès prennent en opérande un vecteur contenant des adresses mémoires, et lit/écrit chacune de ses adresses mémoire indépendamment. Les accès en scatter-Gather peuvent être vus comme une généralisation de l'adressage indirect à registre aux vecteurs, chaque élément du vecteur étant adressé via adressage indirect. Les accès en gather sont des lectures : elles prennent un vecteur d'adresse, lisent chaque adresse, et regroupent toutes les données lues dans un vecteur, dans un registre vectoriel. Les accès en scatter sont l'équivalent pour les écritures. Ils prennent un vecteur d'opérande pour les données à écrire, un vecteur pour les adresses.

 
Adressage en scatter-gather

Un autre version des accès en scatter-gather n'utilise pas des adresses mémoire, amis des indices. Le vecteur opérande ne contient pas des adresses, mais des indices qui sont combinés à une adresse de base pour calculer plusieurs adresses. Un tel mode d'adressage permet de faciliter l'implémentation de certaines structures de données, appelées des vecteurs de Liffe. Ils sont très utilisés pour gérer les matrices creuses, des matrices où une grande partie des éléments sont nuls et où, dans un souci d'optimisation, seuls les éléments non nuls de la matrice sont stockés en mémoire.

Lors d'une instruction de scatter ou de gather, tous les accès mémoire n'ont pas lieu en même temps, certains accès mémoire seront terminés avant les autres. Les accès peuvent se faire dans le désordre et de nombreuses optimisations matérielle en profitent pour gagner en performance. Il existe cependant un cas où les accès mémoire doivent se faire dans un ordre bien précis, généralement du premier élément vers le dernier : lorsqu'une instruction scatter effectue plusieurs écritures à la même adresse. C'est parfaitement possible, et c'est le seul cas où il est intéressant de forcer un ordre pour les écritures.

En théorie, une instruction SIMD est un tout, c'est à dire qu'elle ne doit pas modifier les registres SIMD avant que tous les accès mémoire se soient terminés. Cependant, il est possible qu'une partie des accès mémoire déclenchent des défauts de page ou lèvent des exceptions matérielles, alors que les autres se sont terminés. En théorie, le processeur est censé se débarrasser du résultat des accès mémoire terminés. Mais ce serait un gachis de performance, ce qui fait que le processeur viole la règle voulant que les registres SIMD soient mis à jour en une seule fois à la fin de l'instruction de scatter-gather. Dès qu'un accès mémoire se termine, il écrit son résultat dans le registre SIMD, à l'endroit adéquat.

Cependant, ce comportement pose un problème. Lorsqu'une exception survient, comme lors d'un défaut de page, le processeur exécute la routine d'interruption associée, puis redémarre l'instruction fautive, le second essai étant le bon. Ici, le processeur ne réexecute pas l'instruction à l'identique, mais seulement les accès non-terminés. Mais comment le processeur sait-il quelles accès mémoire redémarrer ? Il doit pour cela mémoriser quels sont les accès mémoire terminés. Il utilise pour cela un registre architectural appelé le masque de complétion. Le registre est forcément architectural car il doit être préservé lors d'un changement de contexte, l’exécution de l'exception matérielle forçant un changement de contexte.

Exemple avec les processeurs x86

modifier
 
Chronologie des extensions x86 SIMD.

Après avoir vu la théorie sur les instructions SIMD, il est temps de voir un exemple concret : celui des instructions SIMD des processeurs x86, présents dans nos PC. Le jeu d'instruction des PC qui fonctionnent sous Windows est appelé le x86. C'est un jeu d'instructions particulièrement ancien, apparu en 1978. Depuis, des extensions x86 ont ajouté des instructions au x86 de base. On peut citer par exemple les extensions MMX, SSE, SSE2, 3dnow!, etc.

Les premières instructions SIMD furent fournies par une extension x86 du nom de MMX, introduite par Intel en 1996 sur le processeur Pentium MMX. Le MMX a perduré durant quelques années avant d'être remplacé par les extensions SSE. La même année, AMD sorti d'expansion 3DNow!, qui ajoutait 21 instructions SIMD similaires à celles du MMX. Celui-ci fût suivi du 3DNow!+ quelques années plus tard. Le SSE fût ensuite décliné en plusieurs versions avant d'être "remplacé" par l'AVX. Une grande quantité de ces extensions x86 sont des ajouts d'instructions SIMD.

L’extension MMX ajoutait pas mal d'instructions SIMD assez basiques, essentiellement des instructions arithmétiques : addition, soustraction, opérations logiques, décalages, rotations, mise à zéro d'un registre, etc. La multiplication est aussi supportée, mais avec quelques petites subtilités, via l'instruction PMULLW. Les instructions MMX ne mettent pas le registre d'état à jour et ne préviennent pas en cas d'overflow ou d'underflow si ceux-ci arrivent (pour les instructions qui ne travaillent pas en arithmétique saturée).

Le MMX introduisait 8 registres vectoriels, du nom de MM0, MM1, MM2, MM3, MM4, MM5, MM6 et MM7, d'une taille de 64 bits, qui ne pouvaient contenir que des nombres entiers. Ils avaient cependant un léger défaut, qui a nui à l'adoption du MMX. Pour comprendre ce défaut, il faut savoir que les flottants sont gérés par l'extension x87, qui définit 8 registres flottants de 80 bits. Et chaque registre MMX correspondait aux 64 bits de poids faible d'un des 8 registres flottants de la x87 ! C'est là une caractéristique courante chez les processeurs Intel, comme nous l'avons vu dans le chapitre sur les registres du processeurs, quand nous avons évoqué le système d'alias de registres. Mais ce système avait un problème assez fâcheux, dans le cas du MMX : il était impossible d'utiliser en même temps l'unité de calcul flottante et l'unité MMX. En faisant ainsi, sauvegarder les registres lors d'un changement de contexte, d'une interruption, ou d'un appel de fonction était très simple : la sauvegarde des registres de la FPU x87 suffisait. Mais cel

 
XMM registers

Dans les années 1999, une nouvelle extension SIMD fit son apparition sur les processeurs Intel Pentium 3 : le Streaming SIMD Extensions, abrévié SSE. Ce SSE fut ensuite complété, et différentes versions virent le jour : le SSE2, SSE3, SSE4, etc. Cette extension fit apparaitre 8 nouveaux registres, les registres XMM. Sur les processeurs 64 bits, ces registres sont doublés et on en trouve donc 16. En plus de ces registres, on trouve aussi un registre d'état qui permet de contrôler le comportement des instructions SSE : celui contient des bits qui permettront de dire au processeur que les instructions doivent arrondir leurs calculs d'une certaine façon, etc. Ce registre n'est autre que le registre MXCSR. Chose étrange, seuls les 16 premiers bits de ce registre ont une utilité : les concepteurs du SSE ont surement préférés laisser un peu de marge au cas où.

La première version du SSE contenait assez peu d'instructions : seulement 70. Le SSE première version ne fournissait que des instructions pouvant manipuler des paquets contenant 4 nombres flottants de 32 bits (simple précision). Je ne vais pas toutes les lister, mais je peux quand-même dire qu'on trouve des instructions arithmétiques de base, avec pas mal d'opérations en plus : permutations, opérations arithmétiques complexes, autres. Petit détail : la multiplication est gérée plus simplement et l'on a pas besoin de s’embêter à faire mumuse avec plusieurs instructions différentes pour faire une simple multiplication comme avec le MMX.

On peut quand même signaler une chose : des instructions permettant de contrôler le cache firent leur apparition. On retrouve ainsi des instructions qui permettent d'écrire ou de lire le contenu d'un registre XMM en mémoire sans le copier dans le cache. Ces instructions permettent ainsi de garder le cache propre en évitant de copier inutilement des données dedans. On peut citer par exemple, les instructions MOVNTQ et MOVNTPS du SSE premiére version. On trouve aussi des instructions permettant de charger le contenu d'une portion de mémoire dans le cache, ce qui permet de contrôler son contenu. De telles instructions de prefetch permettent ainsi de charger à l'avance une donnée dont on aura besoin, permettant de supprimer pas mal de cache miss. Le SSE fournissait notamment les instructions PREFETCH0, PREFETCH1, PREFETCH2 et PREFETCHNTA. Autant vous dire qu'utiliser ces instructions peut donner lieu à de sacrés gains si on s'y prend correctement ! Il faut tout de même noter que le SSE n'est pas seul "jeu d'instruction" incorporant des instructions de contrôle du cache : certains jeux d'instruction POWER PC (je pense à l'Altivec) ont aussi cette particularité.

Avec le SSE2, de nouvelles instructions furent ajoutés, permettant d'utiliser des nombres de 64, 16 et 8 bits dans chaque vecteur. Le SSE2 incorporait ainsi pas moins de 144 instructions différentes. Ce qui commençait à faire beaucoup.

Puis, vient le SSE3, avec ses 13 instructions supplémentaires. Pas grand-chose à signaler, si ce n'est que des instructions permettant d'additionner ou de soustraire tous les éléments d'un paquet SSE ensemble, des instructions pour les nombres complexes, et plus intéressant : les deux instructions MWAIT et MONITOR qui permettent de paralléliser plus facilement des programmes.

Le SSE4 fut un peu plus complexe et fut décliné lui-même en 2 versions. Le SSE4.1 introduit ainsi des opérations de calcul de moyenne, de copie conditionnelle de registre (un registre est copié dans un autre si le résultat d'une opération de comparaison précédente est vrai), de calcul de produits scalaire, de calcul du minimum ou du maximum de deux entiers, des calculs d'arrondis, et quelques autres. Avec le SSE4.2, le vice à été poussé jusqu'à incorporer des instructions de traitement de chaines de caractères.

 
Registres AVX.

Avec l'AVX, on retrouve 16 registres d'une taille de 256 bits, nommés de YMM0 à YMM15 et dédiés aux instructions AVX. Ils sont partagés avec les registres XMM : les 128 bits de poids faible des registres YMM ne sont autres que les registres XMM. L'AVX complète le SSE et ses extensions, en rajoutant quelques instructions, et surtout en permettant de traiter des données de 256 bits.

Son principal atout face au SSE est que les instructions AVX permettent de préciser le registre de destination en plus des registres d'opérandes. Avec le SSE et le MMX, le résultat d'une instruction SIMD était écrit dans un des deux registres d'opérande manipulé par l'instruction. Il fallait sauvegarder son contenu si on en avait besoin plus tard, ce qui n'est plus nécessaire avec l'AVX.

La performance des processeurs SIMD

modifier

Il est intéressant de comparer la performance d'un processeur SIMD et celle d'un processeur normal, sans SIMD. Mais cet exercice est compliqué par le fait que les processeurs non-SIMD sont très nombreux : entre les CPU superscalaires, à émission dans l'ordre, exécution dans le désordre, ceux sans rien de tout cela, on a de quoi être perdu. Dans ce qui suit, nous allons comparer les processeurs SIMD avec deux types de processeurs : les processeurs à émission multiples et les processeurs basiques sans pipeline.

Les processeurs SIMD comparés aux processeurs à émission multiple

modifier

Le SIMD permet d'effectuer plusieurs calculs en parallèle, mais les architectures superscalaires en sont aussi capables, de même que les architectures VLIW. Prenons un processeur SIMD capables d'effectuer 16 calculs entiers/flottants en parallèle maximum, donc avec des vecteurs de 16 éléments. L'équivalent non-SIMD est : soit un processeur VLIW dont les bundles regroupent 16 instructions indépendantes, soit un processeur superscalaire capable d'émettre 16 instructions à la fois. Vous noterez que les trois processeurs en question disposent de 16 ALU pour ce faire.

Les trois architectures ont donc les mêmes performances dans un cas théorique idéal : les trois peuvent exécuter 16 calculs indépendants en même temps. Mais ce serait oublier que les architectures VLIW et superscalaires peuvent effectuer 16 calculs indépendants différents, là où le SIMD applique 16 calculs identiques sur des données différentes. Et ce cas est bien plus fréquent dans les codes généralistes. Le SIMD n'est donc utile que pour des codes spécialisés, où le parallélisme de donnée s'exprime d'une certaine manière.

Il faut cependant nuancer en tenant compte d'un point important : les architectures superscalaires doivent détecter les dépendances entre instructions avant d'éventuellement les exécuter en parallèle. Et cela a un cout en hardware qu'il faut payer. Avec le SIMD, on charge et décode une unique instruction, pas besoin de détecter les dépendances. Et un autre point est que les architectures superscalaires/VLIW exécutent réellement plusieurs instructions en parallèle, qui sont encodées telles quelles en mémoire. Alors qu'avec une architecture SIMD, les 8/16/32 calculs parallèles correspondent à une seule instruction. La densité de code est donc meilleure avec les architectures SIMD, si la situation s'y prête.

Les processeurs SIMD comparés aux processeurs sans pipeline

modifier

Comparé à un processeur sans pipeline, on s'attend à ce que la performance soit augmentée d'un facteur N, avec N le nombre maximal d'entiers/flottants que l'on peut mettre dans un vecteur. Si une architecture SIMD fait N calculs en parallèles, alors les performances sont censées être multipliées par N comparé à un processeur sans pipeline dont l'iPC maximale est de 1. Il laisse alors penser que plus les vecteurs sont longs, meilleur est le gain en performance. Il s'agit là d'un résultat basique, mais qui est assez approximatif, la vraie vie est différente.

Un premier problème est que ce résultat ne tient que si la mémoire RAM et les caches suivent. En effet, faire N calculs en parallèle demande de lire/écrire N fois plus de données. Les données sont souvent stockées dans des registres vectoriels, ce qui fait que la pression sur le banc de registre est assez importante. Mais cela signifie aussi que les instructions d'accès mémoire doivent lire/écrire N fois plus de données. Il faut alors ajouter des ports sur le cache, élargir le bus mémoire, augmenter la taille des lignes de cache, etc. Le débit binaire des caches et de la mémoire RAM deviennent rapidement des points limitants, qui réduisent le gain en performance des instructions SIMD. Et ne parlons pas de l'interaction avec la mémoire virtuelle : vu qu'on lit/écrit par paquets de données, cela signifie qu'on traverse une page mémoire plus vite, ce qui fait que les défauts de page sont lus fréquents. Un processeur SIMD a intérêt à être combiné à une RAM et des caches solides, très performants. C'est le cas sur les processeurs modernes, mais cela a pose des problèmes pour les processeurs vectoriels, et a mené à leur abandon progressif.

Mais passons outre ce problème et regardons la seconde raison qui font que ce résultat est naïf. N'oublions pas la loi d'Amdhal. Toutes les portions d'un programme ne sont pas accélérées par des instructions SIMD, il y a des portions dont les calculs ont des dépendances de données, d'autres qui ne sont pas parallélisables, etc. Une partie du programme est donc impossible à véctoriser (i.e optimiser pour le SIMD), une l'autre l'est. Pour simplifier les explications, on suppose qu'une instruction SIMD prend le même nombre de cycles que son équivalent non-SIMD. Par exemple, une addition SIMD prend le même temps qu'une addition normale. Dans ce cas, la portion non-vectorisable du code reste inchangée, alors que la portion vectorisable est divisée par N, par la largeur du vecteur. On retombe sur une formulation identique à la loi d'Amdhal sur le fond.

Une autre manière de compter le tout est d'utiliser la métrique de l'efficience SIMD. Elle est calculée en divisant deux grandeurs. La première est elle-même un rapport : c'est le gain obtenu en terme de nombre d'instructions exécutées. Prenons un programme qui exécute I instructions avant vectorisation, et qui en exécute I/X après. LE gain s'exprime comme suit :

 

Maintenant, prenons ce gain est divisons-le par la largeur d'un vecteur. On obtient alors l'efficience SIMD :

 

Notons que le gain et la largeur SIMD ne sont égales que dans un cas bien précis : tout le code est vectorisable. Il s'agit donc d'une reformulation du gain de la loi d'Amdhal, mais dans laquelle le pourcentage de code série est caché.

Il est intéressant de regarder l'efficience SIMD quand on augmente la portion du code série, ainsi que la taille des vecteurs. Prenons un cas assez généreux, réaliste pour certaines applications très adaptées au SIMD : 1% du code est non-vectorisable, 99% profite du SIMD. L'efficience SIMD varie alors assez rapidement avec la taille des vecteurs. De 99% pour N=2, elle descend à 98% pour N=16, et tombe sous les 50% pou N=128. La conséquence est que les très grandes tailles de vecteurs ne sont pas vraiment utiles, le rendement est décroissant avec la taille des vecteurs.

Les processeurs SIMD à registres généraux

modifier

Le premier type de processeur SIMD que nous allons voir est celui des processeurs de type SWAR (SIMD Within A Register). Le terme SWAR est un terme polysémique dont le sens a beaucoup changé au fil du temps. Ici, nous allons l'utiliser dans la définition la plus stricte : celle où on effectue du SIMD dans les registres généraux du processeur, sans registres spécialisés. Il s'agit d'une forme de SIMD qui est aujourd'hui peu utilisée, mais qui a été la première forme de SIMD utilisée dans des processeurs grand public commerciaux.

L'idée est d'améliorer un petit peu un processeur normal, non-SIMD. Un processeur usuel contient des registres généraux de 32 à 64 bits, qui stockent chacun un opérande de même taille que le registre. D'anciens processeurs avaient des instructions pour effectuer des calculs simples, sur des opérandes plus courts. Par exemple, les processeurs x86 32 bits sont capables de faire des calculs sur 8, 16, ou 32 bits. Mais on ne pouvait placer qu'un seul opérande de 8, 16 ou 32 bits dans un registre général, ce qui est un léger gâchis. Le SWAR est une amélioration de cette technique qui vise à mieux utiliser les registres et l'ALU.

L'idée est de regrouper plusieurs opérandes de 8, 16, voire 32 bits, dans un seul registre général. De plus, on ajoute des instructions SIMD d'addition/soustraction/autres, qui lisent des opérandes 8/16/32 bits dans ces registres généraux. Par exemple, sur un processeur 32 bits, on peut ajouter une opération d'addition qui lit deux registres de 32 bits, pour récupérer quatre opérandes de 16 bits (deux par registre) et effectue deux additions 16 bits en même temps dans l'ALU. Et il y a la même chose pour d'autres opérations, comme la soustraction, la comparaison, etc.

Les instructions SIMD disponibles sur ces processeurs se limitent généralement à des additions, soustractions, comparaisons, mais guère plus. De plus, il s'agit d'instructions entières, il n'y a pas d'instructions SIMD flottantes. La raison à cela est très simple, mais nous l'expliquerons plus bas, dans la section sur l'implémentation. Disons simplement que de tells architectures visent une économie en circuit maximale. Elles implémentent les instructions SIMD en utilisant le moins de circuits possibles, ce qui fait qu'elles réutilisent les registres généraux et n'ont pas de registres SIMD séparés.

Les extensions/jeux d'instructions de type SWAR

modifier

Les premiers processeurs à intégrer du SWAR étaient les processeurs DEC Alpha, avec l'extension multimédia Motion Video Extensions. Les processeurs en question étaient les processeurs Alpha 21164PC (PCA56 and PCA57), Alpha 21264 (EV6) and Alpha 21364 (EV7). Les instructions SIMD disponibles étaient très simples et se résumaient à quelques comparaisons : trouver le maximum ou le minimum de deux opérandes de 8/16 bits, quelques instructions de permutation.

Les extensions Multimedia Acceleration eXtensions des processeurs Hewlett-Packard PA-RISC sont aussi de ce type, mais disposent de plus d'instructions SIMD. La première version, appelée MAX-1, était disponible sur les processeurs 32 bits de la marque, à partir du processeur PA-7100LC. Les instructions disponibles étaient des additions et soustractions d'opérandes 16 bit. Il y avait en tout trois instructions d'addition et trois pour la soustraction. En tout, il y avait :

  • une instruction d'addition/soustraction non-signée utilisant l'arithmétique modulaire ;
  • une instruction d'addition/soustraction signée en arithmétique modulaire ;
  • une instruction d'addition/soustraction signée en arithmétique saturée.

Outre les additions/soustractions, il y avait aussi une instruction pour calculer la moyenne de deux opérandes 16 bits, ainsi qu'une addition fusionnée avec un décalage.

La seconde version, appelée MAX-2, ajouta des instructions d'additions fusionnées avec des décalages, mais aussi des instructions de permutation. De plus, cette version était disponible sur les processeurs 64 bits de la marque, ce qui fait que les registres étaient eux aussi de 64 bits. Ils pouvaient contenir deux fois plus d'opérandes 16 bits. Il n'y avait de gestion d'opérandes 32 bits pour les extensions SIMD.

L'implémentation matérielle

modifier

L'avantage de cette technique est la grande simplicité d'implémentation. On a juste besoin d'ajouter des instructions dans le décodeur, d'ajouter quelques centaines de portes logiques dans l'ALU, pas plus. De plus, on n'ajoute pas de registres SIMD séparés, ce qui a de nombreux avantages : économie de circuits car pas besoin d'un second banc de registre, pas besoin de sauvegarder des registres SIMD en plus lors d'un appel système/interruption, etc.

L'implémentation a juste besoin de modifier le décodeur et l'unité de calcul. Les modifications de l'unité de calcul sont particulièrement simples. Il suffit de prendre une unité de calcul et de modifier la manière dont elle gère les retenues.

Une première solution est d'utiliser une ALU bit-slicée, ce qui est l'idéal pour les unités de calcul entières. Par exemple, pour une ALU 32 bits entière, on peut la découper en 4 unités de calcul 8 bits. Pour une opération SIMD avec 4 opérandes 8 bits, les quatre ALU fonctionnent en parallèle, il n'y a pas transmission des retenues. Pour les calculs SIMD avec des opérandes de 16 bits, on regroupe les ALU par paire et on propage les retenues à l'intérieur d'une paire, mais pas entre les paires. Enfin, pour un calcul non-SIMD, les opérandes de 32 bits, on propage les retenues normalement, d'une ALU 8 bits vers la suivante.

Une autre solution prend un additionneur 32/64 bits normal, mais mets à 0 les retenues. L'implémentation est très simple avec un additionneur à anticipation de retenues, où les retenues sont calculées en avance, avant de faire l'addition proprement dite, par un circuit d'anticipation de retenue. Il suffit alors d'ajouter un circuit de masquage en sortie du circuit d'anticipation de retenue, qui met à zéro les retenues adéquates. La gestion des débordements demande d'ajouter des circuits, ou du moins de modifier ceux déjà présents dans l'ALU. Typiquement, les circuits pour gérer l'arithmétique saturée sont un peu modifiés pour effectuer la mise à 1111...111 octet par octet et chaque octet est masqué si besoin.

 
ALU modifiée pour implémenter du SWAR

Notons que la technique ne s'applique qu'aux additions, et aux opérations dérivées comme la soustraction et les comparaisons (ces dernières sont des soustractions déguisées). Mais les multiplications ou divisions ne peuvent pas s'implémenter simplement en modifiant des ALU existantes, du moins pas avec des modifications simples. Aussi, les processeurs qui utilisent cette technique se bornent aux additions et opérations dérivées, pas plus.

La gestion des opérations de permutation est quant à elle très simple : beaucoup de processeurs disposent déjà d'instructions de permutation d'octets, qui sont l'équivalent d'instructions SIMD de permutation pour les registres généraux. Nous en avions déjà parlé dans le chapitre sur le langage machine et l'assembleur, quand nous avions fait la liste des instructions les plus courantes. De telles instructions de permutation d'octet sont utiles pour gérer le boutisme ou effectuer quelques manipulations assez rares. Pas besoin de rajouter une ALU dédiée, celle-ci est déjà présente de base, du moins si les instructions de permutation d'octet sont déjà présentes.

Les processeurs SIMD purs (packed SIMD) et SIMT

modifier

Maintenant que nous avons vu les instructions SIMD, passons maintenant aux processeurs eux-même. Tous les processeurs que nous allons voir dans ce qui suit supportent des instructions SIMD. Mais leur implémentation matérielle, leur micro-architecture, n'est pas la même. Ils ont aussi quelques différences en termes de jeu d'instruction, mais qui sont fortement liées à l'implémentation matérielle.

Nous allons ici voir les processeurs SIMD purs, aussi dits de type Packed SIMD. Ils implémentent des instructions SIMD sans rien de plus, juste le strict minimum : pas de prédication, pas de vecteurs de taille variable, presque pas d’instructions horizontales, architecture de type LOAD-STORE. De tels processeurs utilisent plusieurs ALU entières/flottantes qui travaillent en parallèle. De plus, ils disposent de registres SIMD séparés des registres généraux. Voyons ces points immédiatement.

Les instructions SIMD verticales : plusieurs ALU travaillant en parallèle

modifier
 
Parallélisme de données au niveau de l'unité de calcul. Celle-ci contient plusieurs circuits indépendants qui appliquent la même opération sur des données différentes.

Sur un processeur SIMD pur, les vecteurs sont stockés dans des registres séparés des registres généraux. Les vecteurs sont beaucoup plus longs qu'un entier/flottant normal, ils peuvent en regrouper plusieurs. Par exemple, sur un processeur 32 bits, qui gère donc des entiers de 32 bits, les vecteurs peuvent faire 128 ou 256 bits. Un vecteur contient donc plusieurs entiers ou flottants de taille maximale. On n'est pas dans le cas précédent, où registres généraux et SIMD sont les mêmes, ils sont séparés et n'ont pas la même taille.

Les calculs sont effectués en parallèle dans des ALU séparées. Une unité de calcul SIMD contient plusieurs additionneurs/multiplieurs séparés. Par exemple, pour additionner deux vecteurs contenant chacun 16 entiers/flottants, il faut utilise 16 additionneurs entiers et 16 additionneurs flottants. Dans le cas général, une ALU SIMD est composée de plusieurs ALU entières et flottantes regroupées ensemble et avec quelques circuits pour gérer les débordements et d'autres situations. Notons que les ALU travaillent en parallèle, elles font des calculs indépendants.

Notons que les additionneurs dans chaque ALU doivent pouvoir être configurés de manière à gérer des tailles de données différentes. Par exemple, si on prend un vecteur simple, qui peut contenir soit 32 entiers de 16 bits, soit 16 entiers 32 bits, soit 8 entiers 64 bits, alors on doit utiliser 8 additionneurs, mais chacun d'entre eux doit pouvoir être reconfiguré de manière à ne pas propager les retenues au-delà des premiers 16 ou 32 bits.

Un défaut de cette organisation est que le cout en circuits est loin d'être négligeable. Il faut dupliquer des unités de calcul et les coller en rajoutant des circuits, cela utilise beaucoup de transistors. Le cout en circuit est d'autant plus grand que les vecteurs sont longs et le cout est approximativement proportionnel à la taille des vecteurs. Entre des vecteurs de 128 et 256 bits, l'unité de calcul utilisera globalement deux fois plus de circuits avec 256 bits qu'avec 128. Même chose pour les registres, mais c'est là un cout commun à toutes les architectures.

Retenez cependant : l'usage de plusieurs ALU travaillant en parallèle a plusieurs défauts. Premièrement, cela implique des vecteurs de taille fixe, du fait que le nombre d'ALU travaillant en parallèle est fixe. Deuxièmement, des difficultés d'implémentation concernant les instructions de réduction. Détaillons ce second point !

Une autre possibilité est d'utiliser moins d'unités de calcul qu'il n'y a d'éléments dans un vecteur. Par exemple, pour des vecteurs contenant 32 flottants, il se peut qu'il n'y ait que 16 ALU. Les opérations sur les vecteurs sont donc faites en deux fois : une première passe pour les 16 premiers éléments, une seconde passe pour les 16 restants. On économise ainsi pas mal de circuits, mais cela se fait au détriment de la performance globale. L'avantage est que l'ensemble des calculs se fait en une seule instruction machine. L'implémentation est généralement la suivante : le processeur décode une instruction SIMD en deux micro-instructions SIMD plus courtes. Par exemple, pour une instruction SIMD de 32 éléments, elle sera décodée en deux instructions SIMD de 16 éléments, chacune étant exécutée sur l'ALU.

Il est possible de réduire fortement l'impact en performance en doublant la fréquence de l'ALU. Par exemple, pour des vecteurs contenant 32 flottants, le processeur incorpore 16 ALU, mais celles-ci fonctionne à une fréquence double de celle du processeur. L'usage d'une ALU à double fréquence est une technique qui a été utilisée sur le Pentium 4 et quelques processeurs, pour des instructions non-SIMD. Mais pour les processeurs SIMD, elle s'applique à la perfection. L'implémentation est la même que précédemment, sauf que la fenêtre d'instruction et la logique d'émission fonctionnent à double fréquence. Pour limiter la casse, il est préférable d'utiliser une fenêtre d'instruction séparée pour les instructions SIMD, qui sera seule à fonctionner à double fréquence.

Les instructions horizontales : une ALU simple séparée

modifier

Les instructions horizontales posent des difficultés d’implémentation. Elles ne peuvent pas s'implémenter en utilisant plusieurs ALU travaillant en parallèle, contrairement aux instructions verticales, ce qui fait qu'elles utilisent généralement des unités de calcul spécialisées. Et c'est là que la différence entre instructions de manipulation de vecteurs et instructions de réduction vient encore une fois poser problème. Les instructions de manipulation de vecteur sont relativement simples à implémenter : elles ont juste besoin d'une ALU spécialisée, relativement simple, composée de beaucoup de multiplexeurs à configurer convenablement. Mais pour les instructions de réduction, c'est autre chose !

L'implémentation des instructions de réduction est possible mais a un cout en circuits assez prohibitif. Pour additionner N entiers, il faut utiliser un additionneur multi-opérande qui prend beaucoup de circuits et dont le temps de calcul est proche de celui d'une multiplication (très lent). Trouver le maximum et le minimum d'un nombre demandent de faire la même chose avec des comparateurs. Le tout a un cout en circuit non-négligeable, pour accélérer des opérations assez peu fréquentes. Elles sont surtout utilisées pour trouver la somme ou le maximum/minimum d'un tableau, opération séquentielle par nature, avec peu parallélisme de données, peu courante.

Une autre solution utilise une unité SIMD normale, mais intègre un système de contournement, de bypass, qui relierait l'entrée d'une ALU à la sortie d'une autre. Mais le cout lié aux interconnexions est très important : on doit relier chaque ALU à toutes les autres dans le pire des cas, on peut optimiser le tout et réduire un peu la quantité d'interconnexions, mais le cout reste important. Au final, on gagne en circuits ce qu'on perd en interconnexions. Le choix entre les deux solutions est loin d'être facile et dépend du processeur, de la technologie utilisée, du budget en transistors disponibles, etc.

Les processeurs SIMD purs résolvent ce problème assez simplement : ils n'implémentent pas d'instructions de réduction. Par contre, ils implémentent systématiquement des instructions de manipulation de vecteur, comme des permutations ou autres. La raison est que l'on peut émuler les instructions de réduction en utilisant des instructions SIMD de manipulation de vecteur couplées à des additions/multiplications SIMD verticales. Il s'agit donc d'un compromis entre cout en circuits et performance finale. Niveau circuits, on a une unité SIMD avec plusieurs ALU de calcul en parallèle, une unité de manipulation de vecteur séparée assez simple et au cout en circuits raisonnable. Les performances pour les opérations de réduction sont acceptables, le cout en performance est relativement modéré.

L'implémentation des LOAD-STORE à des données consécutives

modifier

Les architectures SIMD pures sont des architectures de type LOAD-STORE, ce qui veut dire que les instructions SIMD ne peuvent que lire ou écrire dans les registres vectoriels, pas en mémoire ou ailleurs. Les transferts entre registres SIMD et mémoire sont le fait d'instructions LOAD et STORE spécialisées, qui transfèrent des vecteurs entre RAM et registres vectoriels. Les autres architectures n'ont pas nécessairement ce genre de restrictions, mais laissons cela pour plus tard.

Implémenter les instructions LOAD/STORE pour des vecteurs dont les données sont consécutives en mémoire n'est pas très compliqué. Il suffit juste d'élargir le port de lecture/écriture du cache L1 de données, pour pouvoir lire/écrire un vecteur entier. Rien de bien compliqué, il suffit juste d'ajouter des interconnexions et d'ajouter des multiplexeurs. Du moins, c'est le cas tant que les vecteurs sont plus petits qu'une ligne de cache, ce qui est souvent le cas en pratique. Dans le cas très rare où les vecteurs sont plus longs qu'une ligne de cache, les LOAD/STORE doivent se faire en deux accès dans le cache, ce qui demande d'ajouter du matériel pour contrôler la situation.

Précisons cependant qu'il s'agit là d'un cas idéal, où les vecteurs sont correctement alignés en mémoire. En effet, il se peut qu'un vecteur soit à cheval sur deux lignes de cache, alors qu'il est plus petit qu'une ligne de cache. Il suffit pour cela qu'il soit placé à une adresse adéquate. Par exemple, prenons un vecteur de 16 octets et des lignes de cache de 32 octets. Si le vecteur démarre à l'adresse 24, alors il sera à cheval sur deux lignes de cache. Il faut donc intégrer des circuits pour gérer cette situation. Pour cela, il suffit d'ajouter un registre pour stocker la première de ligne lue, puis un circuit qui combine les deux lignes de cache pour donner le vecteur final.

L'implémentation des LOAD-STORE en scatter-gather

modifier

L'implémentation des accès en scatter-gather est plus compliquée. Un accès en scatter-gather demande de faire des calculs d'adresse, suivis par un ensemble de lectures/écritures, suivis par un regroupement des données lues dans un vecteur. Le calcul d'adresse peut se faire en parallèle dans une unité de calcul SIMD, dans plusieurs unités de calcul d'adresse en parallèle. Par contre, les lectures/écritures ne le peuvent pas. Le cache n'a pas assez de ports de lecture/écriture pour lire/écrire N données. Il a quelques ports, ce qui lui permet de lire/écrire 3/4 données grand maximum, soit moins que ce qui est nécessaire pour faire un accès en scatter-gather en une fois. Les lectures/écritures sont donc effectuées en plusieurs fois. Pour une opération de Gather, il faut aussi regrouper les données lues dans un vecteur.

Sur les caches des processeurs à haute performance, le calcul d'adresse est intégré dans le décodeur. Ce sont des caches adressés par somme, que nous avions abordé dans le chapitre sur les mémoires caches. Avec de telles caches, on ne peut pas utiliser une unité de calcul d'adresse SIMD, on ne peut calculer qu'autant d'adresse qu'il y a de ports sur le cache.

Les accès en scatter-gather regroupent plusieurs accès mémoire, qui peuvent être effectués dans le désordre. La seule exception est celle où un scatter effectue plusieurs écritures à la même adresse, où les deux écritures doivent se faire dans l'ordre allant du premier au dernier élément du vecteur d'adresse. Les processeurs SIMD profitent de cette absence d'ordre pour effectuer les accès dans un ordre le plus optimisé possible.

Par exemple, imaginons le cas où une instruction gather lise des éléments placés dans deux lignes de cache uniquement, mais dans le désordre en passant sans cesse d'une ligne de cache à l'autre. Il est alors possible de faire tous les accès dans la première ligne de cache en premier, avant de faire ceux allant dans l'autre. Une telle optimisation marche aussi bien pour les lectures que les écritures, pour les gather que pour les scatter. Elle peut aussi être adaptée pour plus de deux lignes de cache. Elle porte le nom de memory coalescing.

Une autre optimisation possible est de détecter le cas où un accès en scatter-gather accède à des données consécutives. Il suffit pour cela que les indices/adresses du vecteur opérande soient consécutives, et cela arrive plus souvent qu'on ne le pense. Dans ce cas, l'accès est remplacé par une instruction LOAD/STORE SIMD normale, beaucoup plus simple et plus rapide. Les deux optimisations demandent que les adresses soient calculées avant de faire l'accès mémoire. Les adresses calculées sont alors comparées entre elles, par un réseau de comparateurs assez complexe.

Un point important de l'implémentation des instructions gather est qu'une donnée chargée doit être insérée au bon endroit dans le vecteur destination. Et pareil pour les scatter : il faut lire la bonne donnée au bon moment. Si on prend une ligne de cache complète, il faut lire plusieurs élèments en même temps et les envoyer au registre de destination au bon endroit. Il faut pour cela tout un réseau de multiplexeurs assez complexe pour faire ce travail. Il y a le même genre de réseau pour les instructions scatter, mais qui va dans l'autre sens : du registre vers la ligne de cache.

Les processeurs SIMD avec prédication

modifier

Un obstacle très gênant à la vectorisation est la présence de branchements conditionnels dans les boucles à vectoriser. Si une boucle contient des branchements conditionnels, elle ne peut pas être vectorisée facilement : il est impossible de zapper certains éléments d'un vecteur suivant une condition. Il n'est pas possible d'effectuer une instruction SIMD seulement sur certains éléments d'un vecteur et d'ignorer les autres, en fonction du résultat d'un branchement conditionnel. Tout le vecteur y passe, ou le vecteur est épargné, mais la condition intermédiaire n'est pas possible avec du SIMD simple.

Pour résoudre ce problème, les processeurs SIMD incorporent diverses techniques pour implémenter ou éviter les branchements conditionnels le plus possible. La plus simple est l'incorporation de certaines instructions SIMD simples, comme la valeur absolue, le calcul du minimum/maximum, etc. Ces opérations sont généralement émulées en utilisant des branchements, avec quelques conditions simples. Incorporer ces instructions permet ainsi de faire disparaitre une partie des branchements du code. Elle ne paye pas de mine, mais elle a un résultat pas négligeable pour le rendu graphique, certaines applications de calcul scientifiques, et quelques autres. Son cout en transistors est très variable, il faut rajouter des circuits dans le séquenceur, l'unité de calcul SIMD est assez peu modifiée (il faut juste rajouter des multiplexeurs).

Le vrai problème est que cette solution laisse énormément de branchements dans le code. Et qu'il faut donc trouver une solution plus générale, capable de réduire drastiquement les branchements. Pour cela, on réutilise une technique vue dans les chapitre sur l'assembleur et le langage machine : la prédication.

Les instructions à prédicats

modifier

Pour optimiser le cas général, il est possible d'utiliser des instructions à prédicats adaptées pour fonctionner sur des vecteurs. L'idée est que quand une instruction effectue un calcul sur un ou deux vecteurs, certains éléments du vecteurs sont ignorés. Les éléments à ignorer sont choisis suivant le résultat d'une instruction de comparaison, qui effectue un test : les éléments pour lesquels ce test est respecté sont pris en compte, ceux qui ne passent pas le test sont ignorés.

Pour donner un exemple d'utilisation, imaginons que l'on ait un vecteur dans lequel on veut remplacer toutes les valeurs négatives par des 0. Dans ce cas, on utilise :

  • une instruction de comparaison, qui compare chaque élément du vecteur avec 0 et génère plusieurs bits de résultat ;
  • suivi d'une instruction à prédicat qui met à zéro les éléments pour lesquels les bits de résultat précédents sont à 1.

La prédication peut aussi être utilisée pour simuler des vecteurs de taille variable. Tous les vecteurs ont une taille fixe, mais on peut ne pas faire de calculs sur la fin d'un vecteur, ce qui fait que tout se passe comme si le vecteur était plus petit. Ce n'est pas sa seule utilisation, mais la prédication permet de simuler ce comportement. Néanmoins, cela n'en fait pas un processeur vectoriel, capable de traiter des vecteurs de taille variable : il n'y a pas de moyen de préciser explicitement la taille des vecteurs à traiter.

Tout cela demande plusieurs modifications du jeu d'instruction : ajouter des instructions de comparaison SIMD, ajouter de quoi faire la prédication.

Le calcul des masques et le registre de masque

modifier

La prédication demande que le résultat d'une comparaison soit stocké dans un registre à prédicat ou dans le registre d'état. Pour les processeurs SIMD, il y a cependant une grosse adaptation à faire concernant le fonctionnement des comparaisons et le stockage des résultats.

Premièrement, les instructions de comparaison SIMD comparent une paire de deux éléments à la même place dans deux vecteurs et fournissent un résultat d'un bit pour chaque paire. Le résultat est donc un ensemble de bits, qui sont regroupées dans un vecteur spécialisé.

Deuxièmement, reste à savoir que faire de ce vecteur. Pour cela, deux solutions sont possibles. Avec la première, le vecteur est stocké dans un registre vectoriel/SIMD comme les autres, il est traité comme n'importe quel autre vecteur, avec cependant un encodage spécifique. Avec la seconde solution, il reçoit un registre dédié, qui est un mix entre registre d'état et registre à prédicat, appelé un registre de masque (Vector Mask Register). Il stocke un bit pour chaque donnée présente dans le vecteur à traiter, qui indique s'il faut ignorer la donnée ou non. L'instruction SIMD suivante fait usage de ce masque pour savoir s'il faut faire l'opération pour chaque élément du vecteur.

 
Vector mask register

Il peut y avoir un ou plusieurs registres de masque. S'il y en a plusieurs, ils sont généralement nommés, sur le modèle des registres à prédicats. L'application d'un masque demande de fournir le nom du registre de masque à utiliser, ils adressés explicitement dans les instructions. S'il n'y en a qu'un seul, il est possible de le pas le nommer et de faire en sorte qu'il soit adressé implicitement par les instructions qui en ont besoin. On parle alors de masquage implicite. Le masquage implicite est très utilisé sur les cartes graphiques, qui sont actuellement des architectures SIMD, comme nous le verrons plus bas.

Un gros problème du masquage implicite est qu'il introduit des dépendances implicites entre instructions, qui ne sont pas explicites quand on regarder uniquement les registres manipulés par une instruction, qui perturbent le renommage de registres. Rien d'insurmontable, mais cela peut poser des problèmes de performance et la résolution de ce problème a un cout en hardware. Mais ce n'est un problème que sur les architectures à exécution dans le désordre. Les architectures à émission dans l'ordre ne sont pas concernées, et c'est notamment le cas sur les GPU et les cartes graphiques modernes.

Diverses optimisations sont possible avec un registre de masque. La première est de ne pas exécuter d'instruction si tous les bits du masque sont à 0. Dans ce cas, l'instruction SIMD n'a pas de calculs à faire, vu que tous les résultats sont masqués. Il est alors possible de ne pas exécuter l'instruction SIMD et de passer directement à la suivante. L'implémentation matérielle est très simple : une porte ET qui détermine si le registre de masque contient un 0. La sortie de cette porte ET est envoyée au séquenceur/décodeur d'instruction. Le décodeur est alors conçu pour zapper l'instruction si le registre de masque contient un zéro.

Les accès mémoire en scatter-gather avec prédication

modifier

Un problème avec les accès mémoire que certains accès peuvent déclencher des défauts de page ou d'autres formes d'exceptions (erreurs de protection mémoire, autre). Si l'accès mémoire se fait sans prédication, ces exceptions sont gérées dans l'ordre des accès mémoire, en commençant par le début du vecteur, ce qui rend l'implémentation assez facile. Mais avec la prédiction, il se peut qu'un accès masqué et censé être ignoré déclenche une exception matérielle. Et le résultat dépend de l'implémentation.

Une première solution est d'effectuer l'accès mémoire, et de masquer ensuite les éléments à ignorer. Mais dans ce cas, les éléments masqués peuvent déclencher des exceptions, qui ne sont pas censées se produire. Une autre solution est de déterminer quelles sont les adresses qu'il faut lire ou écrire et ne pas faire les accès mémoire masqués. Une autre solution effectue tous les accès avant de savoir quels sont ceux à masquer, mais de ne pas déclencher d'exception avant qu'on sache quels sont les accès masqués. Une fois le masquage des accès effectués, le processeur exécute les exceptions adéquates. Il s'agit d'un comportement de masquage d'exception, très utilisé.

L'implémentation est assez simple pour les accès mémoire contiguës, mais plus compliqué pour les accès en stride ou en scatter-gather. Pour les accès contiguës, les seules exceptions pouvant survenir sont celles ayant lieu quand on passe d'une page mémoire à la suivante (au sens pagination, mémoire virtuelle). Et le hardware pour détecter un débordement de page est assez simple. Mais pour les accès en stride ou en scatter-gather demandent qu'oin vérifie les exceptions pour chaque élément du vecteur.

L'implémentation matérielle

modifier

L'implémentation matérielle est assez proche des processeurs SIMD purs. On retrouve plusieurs unités de calcul travaillant en parallèle, avec quelques circuits ajoutés pour gérer la prédication. Le cout supplémentaire en circuits est acceptable, les gains en performance associés sont très intéressants. Les circuits en plus, pour gérer la prédication, sont similaires à ceux utilisés pour la prédication sur les architectures non-SIMD, mais sont dupliqués.

La seule innovation architecturale est l'implémentation du registre de masque. Tous les processeurs n'en utilisent pas, mais c'est la solution la plus performante. En effet, ce registre de masque est un peu l'équivalent du registre d'état pour les instructions SIMD, bien qu'il soit techniquement une concaténation de registres à prédicat non-nommés. Ils ont pour avantage de libérer des registres SIMD normaux tout en ayant un cout en matériel très limité. Un autre point est que pour gérer les masques, les instructions SIMD à prédicat doivent lire le masque. Sans registre à prédicat, en utilisant un registre SIMD normal, il faut rajouter un troisième port de lecture sur le banc de registre pour récupérer le masque, ce qui est couteux en matériel. Pas besoin avec un registre de masque, qui n'est connecté qu'à l'ALU, sur le même modèle que le registre d'état.

Plus haut, on a vu que l'implémentation de l'ALU SIMD peut se faire en utilisant des moins d'ALU que prévu. Par exemple, pour des vecteurs de 32 éléments, on peut utiliser 16 ALUs (allant à double fréquence ou non). Une instruction SIMD est alors décodée en plusieurs micro-instructions SIMD consécutives, chacune traitant une partie d'un vecteur. Par exemple, une instruction SIMD sur des vecteurs de 32 éléments est exécutée par deux micro-instructions travaillant sur des vecteurs de 16 éléments. L'avantage est que cela se marie bien avec l'abandon des opérations pour lesquelles masques dont tous les bits sont à 0. Par exemple, prenons une instruction travaillant sur 16 flottants, exécutée en deux fois sur 8 flottants. Si le masque dit que les 8 premières opérations ne sont pas à exécuter, alors l'ALU ne fera que le calcul des 8 derniers flottants. Pour cela, le décodeur doit lire le registre de masque lors du décodage pour éliminer une micro-instruction si besoin, voire les deux si le masque est coopératif.

La prédication pour les structures de contrôle imbriquées

modifier

Au niveau du jeu d’instruction, les architectures SIMT implémentent de la prédication, sous une forme améliorée. Les processeurs SIMT actuels sont surtout utilisées sur les processeurs intégrés aux cartes graphiques. Et ces derniers gèrent très mal les branchements, et encore : beaucoup de cartes graphiques, même récentes, ne gèrent tout simplement pas les branchements. Elles doivent donc se débrouiller avec uniquement la prédication, là où les processeurs SIMD utilisent des branchements normaux en complément de la prédication. Insistons sur le fait que cet usage exclusif de la prédication n'est présent que sur une sous-partie des architectures SIMT, le seul exemple que l'auteur de ce wikilivre connait étant celui des cartes graphiques.

Les architectures SIMT sans branchements doivent donc trouver des solutions pour gérer les structures de contrôle imbriquées, à savoir une boucle placée à l'intérieur d'une autre boucle, un IF...ELSE dans un autre IF...ELSE, etc. Elles utilisent pour cela la prédication, combinée avec des mécanismes annexes. Le premier d'entre eux est l'usage de plusieurs registres de masques organisés d'une manière bien précise, l'autre est l'usage de compteurs d'activité. Voyons ces deux techniques.

La pile de masques

modifier

La pile de masques remplace le ou les registres de masque. Sans elle, le processeur SIMD incorpore un registre de masque qui est adressé implicitement ou explicitement. Éventuellement, le processeur peut contenir plusieurs registres de masque séparés adressables via un nom de registre. Avec elle, le processeur SIMD incorpore plusieurs registres de masque organisé en pile. Le registre de masque est donc remplacé par une mémoire LIFO, une pile, dans laquelle plusieurs masques sont empilés.

Le tout forme une pile, similaire à la pile d'appel, sauf qu'elle est utilisée pour empiler des masques. Un masque est calculé et empilé à chaque entrée dans une structure de contrôle, puis dépilé une fois la structure de contrôle exécutée. L'empilement et le dépilement des masques est effectué par des instructions PUSH et POP, présentes dans le jeu d'instruction du processeur SIMD.

Le calcul des masques doit répondre à plusieurs impératifs.

  • Premièrement, chaque masque se calcule en faisant un ET entre le masque précédent et le masque calculé par l'instruction de test. Cela permet de ne pas réveiller d’élément au beau milieu d'une structure imbriquée. Si in IF désactive certains éléments du vecteur, une condition imbriquée dans ce IF ne doit pas réveiller cet élément. Le fait de faire un ET entre les masques garantit cela.
  • Deuxièmement, les masques doivent être empilés et dépilés correctement. Au moment de rentrer dans une structure de contrôle, on effectue une instruction de test associée à la structure de contrôle, qui calcule un masque, et on empile le masque calculé. Au moment de sortir de la structure de contrôle, on dépile le masque en question.

L'implémentation demande d'utiliser une mémoire LIFO pour stocker la pile de masques, et quelques circuits annexes. Il faut notamment un circuit relié à l'ALU qui récupère les conditions, les résultats des comparaisons, et qui effectue le ET pour combiner les maques.

Pour donner un exemple, prenons le code suivant, qui est volontairement simpliste et ne sert qu'à des fins d'explication :

if ( condition 1 )
{
    if ( condition 2 )
    {
        ...
    }
    else
    {
        ...
    }

    Autres instructions
}

Instructions après le IF...

Imaginons que l'on traite des vecteurs de 8 éléments.

Pour le vecteur considéré, la première condition (a > 0) n'est respectée que par les 4 premiers éléments. L'instruction de condition calcule alors le masque correspondant : 1111 0000. Le masque est alors calculé, puis empilé au somment de la pile.

La seconde instruction de test, qui teste la variable b, est maintenant valide pour les 4 bits du milieu du masque. Mais n'allez pas croire que le masque correspondant soit 0011 11100 : il faut tenir compte de la condition précédente, qui a éliminé les 4 derniers éléments. Pour cela, on fait un ET logique entre le masque précédent, et le masque calculé par la condition. Le masque au sommet de la pile est donc lu, combiné avec le masque calculé par l'instruction, ce qui donne le masque final. Le masque final est alors empilé au sommet de la pile.

On exécute alors l'instruction du IF, en tenant compte du masque qui est au sommet de la pile. Si le IF était plus compliqué, toutes les instructions suivantes tiendraient compte du masque. En fait, le masque est pris en compte tant qu'il n'est pas dépilé. Une fois que le IF est terminé, le masque est dépilé.

On passe alors au ELSE, et rebelotte. Le masque pour le ELSE est calculé en combinant le masque au sommet de la pile avec la condition du ELSE. Le masque au sommet de la pile est celui calculé à l'entrée du premier IF, pas le second qui a été dépilé. Les instructions du ELSE sont alors exécutées en tenant compte de ce masque. Une fois qu'elles sont toutes exécutées, le masque est dépilé.

Puis vient l'exécution des instructions après le ELSE. Elles utilisent le masque empilé au sommet de la pile, qui correspond à celui à l'entrée du IF.

Puis vient le moment d'exécuter les instructions après le IF : pas de masque, on exécute sur tout le vecteur.

Les compteurs d'activité

modifier

Une variante de la technique précédente remplace la pile de masques par des compteurs d'activité. La technique est similaire, si ce n'est qu'elle utilise moins de circuits. Avant , on avait une pile de masques de même taille, dont les bits sont à 0 ou 1 suivant que la condition est remplie. La pile de masque ressemble donc à ceci :

masque 1 1 1 1 1
masque 2 0 1 1 1
masque 3 0 1 1 1
masque 4 0 0 0 1
masque 1 vide

Une manière équivalent de représenter cette pile de masque est de compter combien de bits sont à 0 dans chaque colonne. Attention : j'ai bien dit à 0 ! On obtient alors :

masque 1 3 1 1 0

Et c'est le principe caché derrière la techniques des compteurs d'activité. Chaque élément dans un vecteur, chaque place, se voit attribuer un compteur. Un compteur non-nul indique qu'il ne faut pas prendre en compte l’élément. Ce n'est qu'une fois que le compteur est nul que l'on effectue des opérations sur l’élément associé du vecteur.

A chaque fois qu'on entre dans une structure de contrôle, on teste une condition sur chaque élément. Si la condition est respectée pour un élément, alors le compteur ne change pas. Mais si la condition n'est pas respectée, alors on incrémente le compteur associé. En sortant de la structure de contrôle, on décrémente le compteur associé. Notons que les compteurs qui n'ont pas été incrémenté en entrant dans la structure de contrôle ne sont pas décrémenté en sortant. En clair, là où on empilait/dépilait un masque, on se contente d'incrémenter/décrémenter un compteur.

Utiliser un compteur en lieu et place d'une colonne entière dans la pile de masque utilise moins de bits. Et c'est sans doute pour cette raison que certaines cartes graphiques, comme les cartes graphiques intégrées d'Intel depuis 2004, utilisent cette technique.

Les processeurs vectoriels

modifier

Les processeurs vectoriels sont les ancêtres des processeurs à instruction SIMD. Bien qu'ils soient arrivés en premier, ils incorporent diverses techniques que de simples instructions SIMD n'ont pas forcément. C'est paradoxal, mais c'est ainsi.

La première différence se manifeste au niveau du jeu d'instruction. Les processeurs vectoriels sont capables de traiter des vecteurs de taille variable, alors que les instructions SIMD usuelles ne gèrent que des vecteurs de taille fixe. Par taille variable, on veut dire que le processeur gère nativement des vecteurs d'une taille allant de 1 à la taille maximale d'un vecteur. La taille maximale d'un vecteur est celle permise par la taille des registres vectoriels, il s'agit de la taille d'un vecteur SIMD équivalent sur les autres processeurs SIMD.

La seconde différence est une différence en termes de micro-architecture. Un processeur SIMD actuel dispose d'une unité de calcul très élaborée, capable de faire plusieurs calculs en parallèle, au moins un pour chaque élément du vecteur. Par exemple, si un vecteur contient 16 entiers, l'ALU SIMD doit contenir au moins 16 additionneurs entiers. Mais sur un processeur vectoriel, ce n'est pas le cas : l'unité de calcul est en réalité une unité de calcul entière/flottante normale, mais qui a la particularité d'être pipelinée. Les calculs sont donc effectués en parallèle, mais d'une manière totalement différente.

La plupart de ces différences s'expliquent par le fait que les anciens processeurs vectoriels devaient faire avec des limitations en termes de circuits. Ils avaient peu de transistors à leur disposition, ce qui fait que leur unité de calcul devait être la plus simple possible. D'où l'utilisation d'une unité de calcul simple, mais utilisée de manière pipelinée. De plus, les processeurs vectoriels ne possèdent aucune mémoire cache pour les données et se contentent juste de caches d'instruction. De tels caches sont généralement peu utiles quand on manipule des tableaux, chose quasiment systématique quand on travaille avec du parallélisme de données.

Une unité de calcul pipelinée

modifier

La différence entre processeur vectoriel et SIMD tient dans la façon dont sont traités les vecteurs : les instructions SIMD traitent chaque élément en parallèle, alors que les processeurs vectoriels pipelinent ces calculs ! Par pipeliner, on veut dire que l’exécution de chaque instruction est découpée en plusieurs étapes indépendantes. Au lieu d'attendre la fin de l’exécution d'une opération avant de passer à la suivante, on peut commencer le traitement d'une nouvelle donnée sans avoir à attendre que l'ancienne soit terminée.

 
Pipeline vectoriel.

Pour donner un exemple, on peut donner l'exemple d'une multiplication flottante effectuée entre deux registres. Son exécution peut être décomposée en plusieurs étapes. Par exemple, on peut avoir 3 étapes :

  • une première étape E qui va additionner les exposants et gérer les diverses exceptions ;
  • une étape M qui va multiplier les mantisses ;
  • et enfin une étape A qui va arrondir le résultat.

L’exécution de notre opération flottante sur un vecteur donnerait donc quelque chose dans le genre, où chaque ligne correspond au traitement d'un nouvel élément dans un vecteur, dans un paquet SIMD.

 
Multiplication vectorielle - pipeline
Certains processeurs vectoriels utilisent plusieurs ALU pipelinées pour accélérer les calculs. On peut les voir comme des intermédiaires entre processeur vectoriels et SIMD SWAR.

Avec une unité de calcul pipelinée découpée en N étages, on peut gérer N données simultanées : autant qu'il y a d'étapes différentes. Mais ce nombre maximal de données met un certain temps avant d'être atteint. L'unité de calcul met du temps avant d'arriver à son régime de croisière. Durant ce temps, elle n'a pas commencé à traiter suffisamment d’éléments pour que toutes les étapes soient occupées. Ce temps de démarrage est strictement égal du nombre d'étapes nécessaires pour effectuer une instruction. La même chose arrive vers la fin du vecteur, quand il ne reste plus suffisamment d’éléments à traiter pour remplir toutes les étapes.

 
Startup and dead time vector pipeline

Pour amortir ces temps de démarrage et de fin, certains processeurs démarrent une nouvelle instruction sans attendre la fin de la précédente : deux instructions peuvent se chevaucher pour remplir les vides. Par contre, il peut y avoir des problèmes lorsque deux instructions qui se suivent manipulent le même vecteur. Il faut que la première instruction ait fini de manipuler un élément avant que la suivante ne veuille commencer à le modifier. Pour cela, il faut avoir des vecteurs suffisamment qui contiennent plus d’éléments qu'il n'y a d'étapes pour effectuer notre instruction.

La technique du chaining

modifier

La technique du pipeline peut encore être améliorée dans certains cas particuliers. L'idée est simplement d'ajouter la technique du contournement (bypass) à l'unité de calcul. Pour rappel, le contournement connecte la sortie de l'ALU à une de ses entrées, ce qui permet au résultat d'un calcul d'être réutilisable au cycle d’horloge suivant. L'usage du contournement sur les processeurs vectoriels porte le nom de Vector Chaining. Un processeur implémentant le chaining/bypass a toutes ses unités de calcul reliées entre elles : la sortie d'une unité est reliée aux entrées de toutes les autres.

Pour comprendre à quoi peut servir cette technique, on peut citer deux grands exemples principaux. Le premier avantage est que l'implémentation des instructions SIMD horizontales est beaucoup plus simple. C'est pour cela que les processeurs vectoriels intègrent souvent des instructions horizontales, et notamment des instructions arithmétiques horizontales. Le contraste avec les processeurs SIMD récents, qui utilisent plusieurs ALU travaillant en parallèle, est frappant. Ces derniers n’intègrent pas d'instructions horizontales arithmétiques, les seules opérations horizontales supportées sont généralement des permutations ou des instructions compress/expand, guère plus.

Maintenant, introduisons une autre possibilité par un exemple. Dans cet exemple, on suppose que le processeur dispose d'une ALU pour l'addition et d'une autre pour la multiplication. Imaginons que l'on ait trois vecteurs nommés A, B et C. Pour chaque énième élément de ces paquets, je souhaite effectuer le calcul  . En théorie, il faudrait faire d'abord la multiplication, stocker le résultat temporaire de la multiplication dans un registre vectoriel, puis faire l'addition. Mais en rusant un peu, on peut utiliser le pipeline plus efficacement. Une fois que le premier élément de la multiplication du premier vecteur est connu, pourquoi ne pas démarrer l'addition immédiatement après, et continuer la multiplication en parallèle ? Après tout, les deux calculs ont lieu dans des ALUs séparés. Et le contournement permet d'alimenter l'ALU d'addition avec le résultat de l'ALU d'addition.

 
Vector chaining

La gestion des vecteurs de taille variable

modifier

L'usage d'une unité de calcul pipelinée fait que la taille des vecteurs n'est pas forcément fixe. Autant les processeurs SIMD non-vectoriels disposent d'un nombre fixe d'unités de calcul, et peuvent donc gérer des vecteurs de taille fixe, autant ce n'est pas le cas des processeurs vectoriels. Une unité de calcul pipelinée à 16 étage peut utiliser les trois premiers pour traiter un vecteur de 3 élements, les 5 suivants pour un second vecteur de 5, et le reste pour un troisième vecteur. Reste que la gestion des vecteurs de taille variable doit être gérée au niveau du séquenceur, mais surtout : doit être permise au niveau du jeu d'instruction.

Pour cela, il faut ajouter au jeu d'instruction de quoi indiquer la taille du vecteur en cours de traitement. Et c'est le rôle d'un registre spécialisé, le Vector Length Register, que de gérer les vecteurs de taille variable. Le Vector Length Register est un registre qui indique combien d’éléments on doit traiter dans un vecteur. On peut ainsi dire au processeur : je veux que tu ne traites que les 40 premiers éléments présents d'un paquet. Ils sont utilisés pour gérer des tableaux dont la taille n'est pas un multiple d'un vecteur. Quand on arrive à la fin d'un tableau, il suffit de configurer le Vector Length Register pour ne traiter que ce qu'il faut.

 
Vector length register.

Il facilite l'usage du déroulage de boucle, une optimisation qui vise à réduire le nombre d'itérations d'une boucle en dupliquant son corps en plusieurs exemplaires. Le compilateur réplique le corps de la boucle (les instructions à répéter) en plusieurs exemplaires dans cette boucle, avant de corriger le nombre d'itérations de la boucle. Par exemple, prenons cette boucle, écrite dans le langage C :

 int i;
 for (i = 0; i < 100; ++i)
 {
     a[i] = b[i] * 7 ;
 }

Celle-ci peut être déroulée comme suit :

 int i; 
 for (i = 0; i < 100; i+=4)
 {
     a[i] = b[i] * 7 ;
     a[i+1] = b[i+1] * 7 ;
     a[i+2] = b[i+2] * 7 ;
     a[i+3] = b[i+3] * 7 ;
 }

Les instructions vectorielles permettent de traiter plusieurs éléments à la fois, ce qui fait que plusieurs tours de boucles peuvent être rassemblés en une seule instruction SIMD. Le déroulage de boucles permet d'exposer des situations où ce regroupement est possible. Dans notre exemple, si jamais notre processeur dispose d'une instruction de multiplication capable de traiter 4 éléments du tableau a ou b en une seule fois, la boucle déroulée peut être vectorisée assez simplement en utilisant une multiplication vectorielle (que nous noterons vec_mul).

 int i; 
 for (i = 0; i < 100; i+=4)
 {
     vec_a[i] = vec_mul ( vec_b[i] , 7 ) ;
 }

Le déroulage de boucles n'est toutefois pas une optimisation valable pour toutes les boucles. Reprenons l'exemple de la boucle vue plus haut. Si jamais le tableau à manipuler a un nombre d’éléments qui n'est pas multiple de 4, la boucle ne pourra être vectorisée, vu que la multiplication vectorielle ne peut traiter que 4 éléments à la fois. Pour ce faire, les compilateurs utilisent généralement deux boucles : une qui traite les éléments du tableau avec des instructions SIMD, et une autre qui traite les éléments restants avec des instructions non vectorielles. Cette transformation s'appelle le strip-mining.

Par exemple, si je veux parcourir un tableau de taille fixe contenant 102 éléments, je devrais avoir une boucle comme celle-ci :

 int i; 
 for (i = 0; i < 100; i+=4)
 {
     a[i] = b[i] * 7 ;
     a[i+1] = b[i+1] * 7 ;
     a[i+2] = b[i+2] * 7 ;
     a[i+3] = b[i+3] * 7 ;
 }
 for (i = 100; i < 102; ++i)
 {
     a[i] = b[i] * 7 ;
 }

Les processeurs vectoriels utilisent le Vector Length Register pour éviter d'utiliser le strip-mining. Avec ce registre, il est possible de demander aux instructions vectorielles de ne traiter que les n premiers éléments d'un vecteur : il suffit de placer la valeur n dans le Vector Length Register. Évidemment, n doit être inférieur ou égal au nombre d’éléments maximal du vecteur. Avec ce registre, on n'a pas besoin d'une seconde boucle pour traiter les éléments restants, et une simple instruction vectorielle peut suffire.

L'avantage principal est que cela réduit la portion de code non-parallélisable, donc augmente l'efficience SIMD. Mais l'intérêt n'est pas qu'une question de code. Il faut aussi prendre en compte que cela permet d'utiliser l'ALU plus efficacement. Dès qu'un vecteur court est terminé, le processeur peut immédiatement démarrer le calcul d'un autre vecteur, le vecteur suivant, l'instruction suivante, sans laisser de cycles inutilisés. Avec un processeur SIMD non-vectoriel, on aurait du utiliser de la prédication, donc utiliser seulement une partie des ALU SIMD, ce qui aurait été un gâchis.

Les accès mémoire

modifier

Les accès mémoire des processeurs vectoriels sont sensiblement les mêmes que ceux des autres processeurs SIMD. Les instructions d'accès mémoire sont les mêmes, on retrouve les modes d'adressages précédents, dont les accès en scatter-gather et en stride.

Sur certains processeurs vectoriels, les instructions vectorielles lisent et écrivent en mémoire RAM, sans passer par des registres : on parle de processeurs vectoriels mémoire-mémoire. Elles n'avaient pas d'instructions LOAD et STORE, les instructions arithmétiques géraient directement la lecture des opérandes en mémoire, et incrémentaient automatiquement les adresses à lire/écrire. Elles pouvaient gérer des vecteurs de taille arbitraire, la gestion d'un tableau se faisait parfois en une seule instruction ! Le problème est que l'absence de registres pour stocker les données lues réduisait grandement les performances.

 
Processeur vectoriel mémoire-mémoire

Les processeurs vectoriels mémoire-mémoire étaient cependant minoritaires, la grosse majorité des processeurs vectoriels récents possèdent des registres vectoriels dédiés aux vecteurs. La plupart sont des architectures de type LOAD-STORE, mais pas toute. De fait, il n'y a pas de lien strict entre adressage des opérandes et processeurs vectoriel (ou SIMT). Seuls les processeurs SIMD de type SWAR ont une restriction là-dessus et sont systématiquement des architectures LOAD-STORE.

 
Processeur vectoriel à registres vectoriels.

Il faut noter que l'implémentation des accès mémoire est potentiellement différente de celle des autres processeurs SIMD. En principe, on peut utiliser une implémentation identique entre les deux. Mais le fait que les processeurs vectoriels sont pipelinés fait qu'il est préférable d'utiliser une implémentation différente, qui se base sur une RAM ou des caches pipelinés. Vu que l'ALU reçoit une/deux opérandes par cycle, l'idéal est que la mémoire ou le cache aillent au même rythme et soient donc pipelinés.Bien sur, il est possible d'utiliser une implémentation plus classique, avec des caches multiports, un chargement en plusieurs fois des vecteurs depuis le cache, des circuits pour réorganiser les accès mémoire, etc.

En théorie, les architectures vectorielles font peu usage de mémoires cache. Les transferts entre registres vectoriels et mémoire RAM sont directs, sans intermédiaire. La raison est que les applications qui tirent parti du SIMD ont une bonne localité spatiale, mais une mauvaise localité temporelle. Or, les caches de données profitent surtout de la localité temporelle pour donner de bonnes performances. De plus, rappelons que les processeurs vectoriels sont assez anciens et datent d'une époque où les transistors étaient précieux. Il valait mieux utiliser des transistors dans un gros cache d'instruction que pour un cache de donnée peu efficace.

Les systèmes SIMD à plusieurs processeurs : le SIMT

modifier

Enfin, terminons avec le dernier type de SIMD, qui date du tout début de l'informatique : celui où on combine plusieurs processeurs non-SIMD. L'idée est de synchroniser plusieurs processeurs de manière à ce qu'ils exécutent la même instruction en même temps, chacun sur des données différentes. On parle d'exécution lockstep des instructions. Toute la difficulté est de garantir la synchronisation des processeurs, garantir que tous les processeurs exécutent la même instruction. Ce qui pose problème quand des branchements sont impliqués, des processeurs pouvant prendre le branchement et pas les autres.

Les array processors des temps anciens

modifier

Les premières architectures matérielles conçues pour le parallélisme de données étaient de ce type. On peut par exemple citer l'exemple des Thinking machines CM-1 et CM-2, qui exécutaient tous la même instruction au même cycle d'horloge sur 64000 processeurs minimalistes, ou encore l'exemple de l'architecture SOLOMON et de l'ILLIAC IV. Mais ce genre de solution ne peut être efficace et rentable que sur des grosses données, car elle nécessite des ordinateurs assez chers. N'espérez pas commander ce genre d’ordinateurs pour noël ! Et de toute façon, cette technique n'a qu'un intérêt historique, aucun ordinateur ne fonctionne comme cela de nos jours.

Historiquement, le terme utilisé pour désigner de telles architectures était array processors, encore que ce terme fût réservé aux systèmes multiprocesseurs. Par la suite, le terme Single Instruction Multiple Threads, ou SIMT, a été utilisé. Mais ce terme a ensuite été repris par le marketing de NVIDIA pour désigner une technique précise de SIMD avec prédication, utilisée sur ses cartes graphiques récentes. Le mauvais usage de la terminologie fait que le terme SIMT est devenu polysémique et quelque peu trompeur.

Les anciennes cartes graphiques hybrides SIMD-VLIW

modifier

Une technique de SIMD multi-processeur a été utilisée autrefois sur certaines cartes graphiques AMD à architecture Terascale. La différence est que l'architecture en question était multicœur et non multi-processeurs. Elles contenaient plusieurs cœurs VLIW, qui fonctionnaient en lockstep. Un groupe de 16 processeurs VLIWs exécutaient la même opération VLIW. Du moins, c'est ce que semblent dire les white papers de présentation de l'architecture GCN. L'usage de cet hybride SIMD-VLIW était très adapté au traitement graphique.

Les cartes graphiques post-années 2000 sont capables d'exécuter des programmes appelés des shaders, qui sont utilisés en rendu 3D, pour calculer certaines scènes 3D, et notamment pour calculer les ombres (d'où leur nom de shader, pour shade - ombre). Or, ces shaders sont justement exécutés par la carte graphique, sur les processeurs de shaders. Un processeur de shader applique un programme/shader sur chaque pixel ou triangle d'une scène 3D (en fait des fragments et des vertices, mais faisons cette simplification). Chaque triangle ou pixel d'une scène 3D peut être traité indépendamment des autres, ce qui rend le traitement 3D fortement parallèle. Aussi, l'usage du SIMD est assez naturel : chaque processeur exécute un shader sur un pixel/triangle, utiliser une architecture SIMD parait naturel. Reste à justifier l'usage de plusieurs CPU VLIW.

La raison tient dans la manière dont est traité un pixel par un shader. Un pixel est codé en utilisant quatre informations. Premièrement, une couleur codée avec trois nombres flottants, qui représentent une couleur au format RGB avec un mélange de rouge, vert et bleu, chaque couleur rouge/bleu/vert étant codée avec un flottant de 32 bits. La quatrième information est un nombre qui code la transparence du pixel. Généralement, le shader traite différemment la transparence de la couleur RGB, dans le sens où les instructions utilisées ne sont pas les mêmes. Au lieu de traiter couleur RGB et transparence séparément, elles sont traitées en même temps en parallèle, ce qui demande de pouvoir émettre deux instructions : une pour le calcul de la transparence, une autre pour la couleur RGB. Fait amusant, l'instruction utilisée pour gérer la composante RGB est une instruction vectorielle qui travaille sur des vecteurs de 3 couleurs. Les architectures VLIW sont parfaites pour cela.

Les cartes graphiques de l'époque de DirectX 9 utilisaient cette méthode qui mélangeait SIMD et VLIW. Elles disposaient de plusieurs cœurs SIMD qui exécutaient le même shader en exécution lockstep. Chaque cœur traitait au moins un pixel à la fois, ou un triangle à la fois. L'architecture était aussi combinée avec du Fine Grained Multithreading afin de pouvoir gérer les accès mémoire, de telles architectures ne pouvant pas se permettre d'utiliser l'exécution dans le désordre. De telles architectures multicœurs à exécution lockstep permettent quelques optimisations, comme le fait de partager le cache d'instruction entre les cœurs.

Résumé des différents types de processeurs SIMD

modifier

Dans ce chapitre, nous avons appris qu'il existe plusieurs catégories de processeurs SIMD. Les différences entre ces catégories tiennent à la fois dans le jeu d'instruction, mais aussi dans la microarchitecture des processeurs en question.

Au niveau du jeu d'instruction, il y a trois paliers, qui sont résumés dans le tableau ci-dessous.

Support de la prédication Taille des vecteurs Instructions horizontales Architecture LOAD-STORE Pointeur de pile
Processeurs SIMD purs Non Fixe Limitées à des échanges de données à l'intérieur d'un vecteur, pas d'instructions de réduction Oui Unique
Processeurs SIMD avec prédication Oui, par définition Dépend du processeur
Processeurs SIMT Oui, sauf exceptions Un pointeur de pile possible par élément du vecteur
Processeurs vectoriels Variable Toutes supportées, y compris les instructions arithmétiques horizontales Unique

Au niveau de la microarchitecture, il y a trois paliers, qui sont résumés dans le tableau ci-dessous.

Unité de calcul Banc de registre Adressage des opérandes
Processeurs SIMD Plusieurs ALU simples non-pipelinée, plusieurs additionneurs/multiplieurs/autres Banc de registres vectoriels Architecture LOAD-STORE
Processeurs SIMT Plusieurs bancs de registres scalaires (non-vectoriels, registres normaux) Dépend du processeur
Processeurs vectoriels ALU simple unique, pipelinée Banc de registres vectoriels

Les processeurs SIMD purs ont des performances différentes des processeurs vectoriels, vu que les premiers font leurs calculs en parallèle, alors que les CPU vectoriels utilisent un pipeline. Mais dans les grandes lignes, les performances sont similaires. Les différences sont grandement atténuées par l'usage du vector chaining, de la gestion des vecteurs de taille variables, et des autres optimisations spécifiques aux processeurs vectoriels. L'avantage est cependant du côté des processeurs SIMD pur.