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.

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.

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 trois catégories principales : les processeurs à instructions SIMD, 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 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 un plusieurs nombres entiers ou flottants regroupés dans ce qu'on appelle un vecteur. Une instruction vectorielle agit, comme son nom l'indique, sur un vecteur, ce qui veut dire qu'elle traite tous les entiers ou flottants du vecteur en parallèle, simultanément.

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

  • Les instructions SIMD horizontales 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 verticales 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 registres vectoriels modifier

Les processeurs SIMD contiennent des registres spécialisés pour les vecteurs. 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é.

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.

Un processeur vectoriel peut incorporer des registres qui facilitent le traitement des diverses instructions. Parmi ces registres, nous aborderons ultérieurement le Vector Length Register, et le Vector Mask Register.

Le Vector Length Register modifier

Le Vector Length Register permet de gérer les tableaux dont la taille n'est pas un multiple d'un vecteur. Celui-ci 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. 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 pou é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.

Le Vector Mask Register modifier

Autre obstacle à la vectorisation : 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. Pour résoudre ce problème, les processeurs vectoriels utilisent un registre de masque, ou Vector Mask Register. Celui-ci stocke un bit pour chaque donnée présente dans le vecteur à traiter, qui indique s'il faut ignorer la donnée ou non.

 
Vector mask register

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. Sur certains processeurs assez anciens, les instructions vectorielles lisent et écrivent en mémoire RAM, sans passer par des registres : on parle de processeurs memoire-mémoire. Mais de nos jours, tous les processeurs vectoriels récents possèdent des registres vectoriels dédiés aux vecteurs.

 
Processeur vectoriel à registres vectoriels.

Dans le cas le plus simple, les données d'un vecteur sont contigües en mémoire et l'instruction peut utiliser le mode d'adressage vectoriel absolu. Avec ce mode d'adressage, les instructions ont juste à préciser l'adresse mémoire du début du vecteur. Avec des vecteurs de 8 octets, toute instruction d'accès mémoire utilisant le mode d'adressage absolu 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 vectorielle 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 vectoriels incorporent aussi les accès en scatter-gather. 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. Cet accès demande d'avoir la liste des adresses mémoires des données. Quand une instruction exécute un accès en Scatter, les données présentes dans ces adresses sont rassemblées dans un vecteur, et enregistrée dans un registre vectoriel. Bien sûr, il existe aussi l’inverse pour l'écriture : c'est l'accès en Gather. Cet accès sert à mieux 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.

 
Adressage en scatter-gather

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 modifier

Le 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

L’extension SSE modifier

 
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, instructions pour charger des données depuis la mémoire dans un registre. 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.

L’extension AVX modifier

 
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.

Les processeurs SIMT (Single instruction, multiple threads) modifier

Le SIMT (Single instruction, multiple threads) est une amélioration du SIMD précédent. Et ce n'est pas une technique récente, mais une technologie utilisée sur les toutes premières architectures parallèles. Les premières architectures matérielles conçues pour le parallélisme de données utilisaient plusieurs processeurs qui exécutaient la même instruction, chacun sur des données différentes. 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 cette solution a de nombreux problèmes : il faut 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). De plus, 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 !

Ces architectures implémentaient du SIMT dans sa forme la plus pure, avec des processeurs séparés. Mais il est possible de faire la même chose avec des cœurs. Pour simplifier, tout se passe comme si on avait plusieurs cœurs qui partagent le même séquenceur, mais dont les chemins de données restent séparés. Les cœurs partagent un unique décodeur d'instruction, un unique cache d'instruction, un unique séquenceur, un seul program counter. Par contre, ils disposent de leur propre unité de calcul, de leur propre banc de registre, et même de leurs propres caches.

La différence avec le SIMD se situe au niveau de l'organisation des registres et des caches. Avec le SIMD, on a plusieurs unités de calcul séparées, qui sont alimentées par un banc de registre SIMD unique (dans le cas le plus simple). Avec le SIMT, on a plusieurs unités de calcul, mais chacune dispose de son propre banc de registre. Les registres sont de plus des registres normaux avec le SIMT, et non des registres SIMD. L'organisation des caches est différente : chaque banc de registre est relié à son propre cache avec le SIMT, alors que le SIMD utilise un cache L1 partagé pour toutes les unités de calcul. Une autre différence majeure est que chaque processeur dispose de son propre pointeur de pile, qui peuvent diverger entre deux processeurs.

Les processeurs vectoriels modifier

Les instructions SIMD sont assez rudimentaires, et certains processeurs assez anciens allaient un peu plus loin : ces processeurs sont ce qu'on appelle des processeurs vectoriels. Ils incorporent diverses techniques que de simples instructions SIMD n'ont pas forcément. Les processeurs vectoriels ne font pas leurs calculs de la même manière que les processeurs SIMD, et n'accèdent pas à la mémoire de la même façon. Par exemple, pour permettre les accès en scatter-gather, les processeurs vectoriels sont souvent combinés à des mémoires multiports ou pipelinées. De plus, les processeurs vectoriels ne possèdent aucune mémoire cache pour les données (même s'ils ont des caches d'instruction).

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

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. Imaginons que l'on ait trois paquets : 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.

Pour ce faire, on doit modifier notre pipeline de façon à ce que le résultat de chaque étape d'un calcul soit réutilisable au cycle d’horloge suivant. La sortie de l'unité de multiplication doit être connectée à l'entrée de l'ALU d'addition. Un processeur implémentant le chaining a toutes ses unités de calcul reliées entre elles de cette façon : la sortie d'une unité est reliée aux entrées de toutes les autres. Il s'agit de ce qu'on appelle le Vector Chaining.

 
Vector chaining

Les unités de calcul parallèles modifier

Certains de nos processeurs vectoriels utilisent plusieurs ALU pour accélérer les calculs. En clair, ils sont capables de traiter les éléments d'un vecteur dans des ALU différentes. Vu que nos ALU sont pipelinées, rien n’empêche d’exécuter plusieurs instructions vectorielles différentes dans une seule ALU, chaque instruction étant à une étape différente. Mais d'ordinaire, les processeurs vectoriels comportent plusieurs ALUs spécialisées : une pour les additions, une pour la multiplication, une pour la division, etc.

Les cartes graphiques modernes modifier

Les cartes graphiques modernes, programmables, sont des architectures massivement parallèles. Il faut dire que 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. De fait, une carte graphique est conçue pour effectuer un maximum de calculs en parallèle, quitte à réduire la puissance pour des calculs séquentiels (ceux qu'effectue un processeur). Les cartes graphiques récentes incorporent toutes les techniques de parallélisme de donnée au niveau matériel : elles ont plusieurs processeurs, ceux-ci sont capables d’exécuter des instructions SIMD (ils ne font que cela, à vrai dire), et j'en passe. L'architecture d'une carte graphique récente est illustrée ci-dessous. On y voit la présence d'un grand nombre de processeurs/cœurs et une hiérarchie mémoire assez étagée avec des mémoires locales en complément de la mémoire vidéo principale.

Les processeurs de shaders modifier

Une carte graphique contient de nombreux processeurs, qui eux-mêmes contiennent plusieurs unités de calcul. Savoir combien de cœurs contient une carte graphique est cependant très compliqué, car la terminologie utilisée par les fabricants de carte graphique est particulièrement confuse. Ce qui est appelé cœur ou processeur dans leur terminologie correspond parfois à une unité de calcul, sans doute histoire de gonfler les chiffres. Dans ce qui va suivre, nous allons utiliser le terme "processeur de shaders" pour désigner les processeurs d'une carte graphique. La raison tient dans la terminologie utilisée en rendu 3D. Les shaders sont des programmes 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. Le terme shader a depuis été élargit et désigne aujourd'hui tout programme qui s’exécute sur une carte graphique.

Notons que ces processeurs sont utilisés pour les calculs graphiques, mais l'on peut aussi les utiliser pour du calcul scientifique ou toute autre application demandant beaucoup de calculs arithmétiques en parallèle. Depuis l'apparition des technologie CUDA et assimilées, les cartes graphiques ne sont plus limitées aux calculs 3D. Si les anciennes cartes graphiques étaient limitées au rendu 3D, elle avaient techniquement des processeurs capables de faire des calculs assez variés et pouvaient être adaptées à des applications très diverses. Mais l'accès au processeurs de shaders se faisait par l'intermédiaire d'un processeur de commande, qui ne comprenait que les commandes graphiques. On pouvait envoyer des scènes 3D à la carte 3D, la faire exécuter des commandes de rendu graphique codées avec un standard bien précis, mais rien d'autre. Mais de nos jours, le processeur de commande s'est complexifié et peut accepter des commandes très diverses, pas seulement des commandes graphiques, mais aussi des commandes de calcul ou des commandes pour exécuter n'importe quel code sur la carte graphique.

Il a existé des cartes graphiques AMD assez anciennes qui utilisaient des processeurs de type VLIW, mais ce n'est plus en odeur de sainteté de nos jours. Si on omet cette exception, les processeurs de shaders sont tous des processeurs SIMD ou des dérivés (la technique dites du SIMT est une sorte de SIMD amélioré). Notons que le jeu d'instruction SIMD utilisé pour coder les shaders a beaucoup évolué dans le temps, avec l'évolution des normes de Direct X et Open GL. Les premières versions des shaders n'imposaient que quelques instructions très simples, 17 au total, toutes travaillant sur des nombres flottants, sans branchements ou accès mémoire proprement dit. Mais avec le temps, l'ensemble s'est étoffé, chaque nouvelle version de Direct X ajoutant de nouvelles instructions aux précédentes. Les shaders actuels sont capables d'effectuer des calculs sur des nombres entiers, des branchements, des accès mémoire, des registres de masque, disposent de modes d'adressages typiques des architectures SIMD, incorporent un mask register et des accès en scatter-gather typiques des processeurs vectoriels. Pour faire simple, ces processeurs de shaders sont un intermédiaire entre processeurs SIMD et processeurs vectoriels : leur jeu d'instruction est celui d'un processeur vectoriel, avec les modes d'adressages et la prédication, mais ils n'utilisent pas d'unité de calcul en pipeline et préférent utiliser des unités de calcul SIMD.

La microarchitecture des processeurs de shaders est de plus particulièrement simple. On n'y retrouve pas les fioritures des CPU modernes, tant utiles pour du calcul séquentiel : pas d’exécution dans le désordre, de renommage de registres, et autres techniques avancées. En conséquence, les unités de décodage et/ou de contrôle sont relativement simples, peu complexes. La majeure partie du processeur est dédié aux unités de calcul.

 
Comparaison entre l'architecture d'un processeur généraliste et d'un processeur de shaders.

La cohérence de caches sur les GPU modifier

Un autre point important des GPU est la manière dont est gérée la cohérence mémoire. En pratique, les caches d'un GPU sont gardés incohérents et aucun protocole de cohérence de cache n'est utilisé. Et ce n'est pas un problème, car le rendu 3D implique un parallélisme de donnée : des processeurs/cœurs différents sont censés travailler sur des données différentes. Il est donc rare qu'une donnée soit traitée en parallèle par plusieurs cœurs, et donc qu'elle soit copiée dans plusieurs caches. La cohérence des caches est donc un problème bien moins important sur les GPU que sur les CPU. En conséquence, les GPU se contentent d'une cohérence des caches assez light, gérée par le programmeur. Si jamais une opération peut mener à un problème de cohérence des caches, le programmeur doit gérer cette situation de lui-même.

Pour cela, les GPU supportent des instructions machines spécialisées, qui vident les caches. Par vider les caches, on veut dire que leur contenu est rapatrié en mémoire RAM, et qu'ils sont réinitialisé. Les accès mémoire qui suivront l'invalidation trouveront un cache vide, et devront recharger leurs données depuis la RAM. Ainsi, si une lecture/écriture peut mener à un défaut de cohérence problématique, le programmeur doit insérer une instruction pour invalider le cache dans son programme avant de faire l'accès mémoire potentiellement problématique. Ainsi, on garantit que la donnée chargée/écrite est lue depuis la mémoire vidéo, donc qu'il s'agit d'une donnée correcte. Nous avons vu plus haut que c'est cette technique qui est utilisée pour les caches de textures. Ce cas est assez particulier car les textures sont censées être accédée en lecture uniquement, sauf dans de rares cas de techniques de render-to-texture. Aussi, ce modèle d'invalidation du cache au besoin est parfaitement adapté. Les autres caches spécialisés fonctionnent sur le même principe. Même chose pour les caches généralistes, bien que certains GPU modernes commencent à implémenter des méthodes plus élaborées de cohérence des caches.