Les cartes graphiques/Les processeurs de shaders

Un livre de Wikilivres.
Sauter à la navigation Sauter à la recherche

Au fur et à mesure que les procédés de fabrication devenaient de plus en plus étoffés, les cartes graphiques pouvaient incorporer un plus grand nombre de circuits. Les unités géométriques, autrefois câblées, sont devenues des unités programmables. Les unités de traitement de la géométrie deviennent donc des processeurs indépendants, capable d’exécuter des programmes sur des vertices. Ces programmes sont appelés des Vertex Shaders. Par la suite, l'étape de traitement des pixels est elle aussi devenue programmable. Des programmes capables de traiter des pixels, les pixels shaders ont fait leur apparition. Une seconde série d'unités a alors été ajoutée dans nos cartes graphiques : les processeurs de pixels shaders. Ils fonctionnent sur le même principe que les processeurs de vertex shaders, mais leur jeu d'instruction était quelque peu différent. En effet, les premières cartes graphiques avaient des jeux d'instructions séparés pour les unités de vertex shader et les unités de pixel shader. Et les processeurs étaient séparés. Pour donner un exemple, c'était le cas de la Geforce 6800. Depuis DirectX 10, ce n'est plus le cas. Depuis, le jeu d'instructions a été unifié entre les vertex shaders et les pixels shaders.

Jeu d'instruction[modifier | modifier le wikicode]

Les shaders sont souvent écrits dans un langage de haut-niveau : le HLSL pour les shaders Direct X et le GLSL pour les shaders Open Gl. Ils sont traduits (compilés) à la volée par les pilotes de la carte graphique, pour les rendre compatibles avec le processeur de vertex shaders. Au début, ces langages, ainsi que le matériel, supportaient uniquement des programmes simples. Au fil du temps, les spécifications de ces langages sont devenues de plus en plus riches à chaque version de Direct X ou d'Open Gl, et le matériel en a fait autant. Les premiers processeurs de shaders disposaient de peu d'instructions. On trouvait uniquement des instructions de calcul arithmétiques, dont certaines étaient assez complexes (logarithmes, racines carrées, etc). Depuis, d'autres versions de vertex shaders ont vu le jour. Pour résumer, les améliorations ont portées sur :

  • le nombre de registres ;
  • la taille de la mémoire qui stocke les shaders ;
  • le support des branchements ;
  • l'ajout d'instructions d'appel de fonction ;
  • le support de fonctions imbriquées ;
  • l'ajout d'instructions de lecture/écriture en mémoire centrale ;
  • l'ajout d'instructions capables de traiter des nombres entiers ;
  • l'ajout d'instructions bit à bit.

Jeu d'instruction[modifier | modifier le wikicode]

Sur tous les processeurs de traitement de vertices, il est possible de traiter plusieurs morceaux de vertices à la fois. Même chose pour les processeurs de pixels shaders, qui peuvent traiter plusieurs pixels à la fois. Ces processeurs sont dit parallèles, à savoir qu'ils peuvent faire plusieurs calculs en parallèle dans des unités de calcul séparées. Il existe plusieurs types de processeurs de shaders, qui se distingue par la manière dont les calculs sont faits en parallèle :

  • les processeurs SIMD et VLIW, avec un parallélisme au niveau du jeu d'instruction ;
  • les processeurs scalaires, sans parallélisme (du moins, au niveau du jeu d'instruction).

Les processeurs VLIW étaient autrefois utilisés sur les anciennes RADEON d'AMD, mais ne sont plus vraiment utilisées aujourd'hui, la mode étant aux processeurs SIMD ou scalaires. Nous parlerons des processeurs SIMT dans la section sur la microarchitecture, ceux-ci ayant un jeu d'instruction non-parallèle, mais convertissent les instructions séries en instructions parallèles à la volée.

Processeurs SIMD[modifier | modifier le wikicode]

Les instructions des processeurs SIMD sont des instructions vectorielles : elles travaillent sur des vecteurs, des ensembles de plusieurs nombres entiers ou nombres flottants placés les uns à côté des autres, le tout ayant une taille fixe. Une instruction de calcul vectoriel va traiter chacune des données du vecteur indépendamment des autres. Par exemple, une instruction d'addition vectorielle 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. Quand on exécute une instruction sur un vecteur, les données présentes dans ce vecteur sont traitées simultanément.

Instructions SIMD

La première carte graphique commerciale destinée aux gamers à disposer d'une unité de vertex programmable est la Geforce 3. Celui-ci respectait le format de vertex shader 1.1. L'ensemble des informations à savoir sur cette unité est disponible dans l'article "A user programmable vertex engine", disponible sur le net. Le processeur de cette carte était capable de gérer un seul type de données : les nombres flottants de norme IEEE754. Toutes les informations concernant la coordonnée d'une vertice, voire ses différentes couleurs, doivent être encodées en utilisant ces flottants. De nos jours, les processeurs de vertices sont capables de gérer des nombres entiers, et les instructions qui vont avec. Ce processeur est capable d’exécuter 17 instructions différentes. Voici la liste de ces instructions :

OpCode Nom Description
MOV Move vector -> vector
MUL Multiply vector -> vector
ADD Add vector -> vector
MAD Multiply and add vector -> vector
DST Distance vector -> vector
MIN Minimum vector -> vector
MAX Maximum vector -> vector
SLT Set on less than vector -> vector
SGE Set on greater or equal vector -> vector
RCP Reciprocal scalar-> replicated scalar
RSQ Reciprocal square root scalar-> replicated scalar
DP3 3 term dot product vector-> replicated scalar
DP4 4 term dot product vector-> replicated scalar
LOG Log base 2 miscellaneous
EXP Exp base 2 miscellaneous
LIT Phong lighting miscellaneous
ARL Address register load miscellaneous

Comme on le voit, ces instructions sont presque toutes des instructions arithmétiques : multiplications, additions, exponentielles, logarithmes, racines carrées, etc. À coté, on trouve des comparaisons (SDE, SLT), une instruction MOV qui déplace le contenu d'un registre dans un autre, et une instruction de calcul d'adresse. Fait intéressant, toutes ces instructions peuvent s’exécuter en un seul cycle d'horloge. On remarque que parmi toutes ces instructions arithmétiques, la division est absente. Il faut dire que la contrainte qui veut que toutes ces instructions s’exécutent en un cycle d'horloge pose quelques problèmes avec la division, qui est une opération plutôt lourde en hardware. À la place, on trouve l'instruction RCP, capable de calculer 1/x, avec x un flottant. Cela permet ainsi de simuler une division : pour obtenir Y/X, il suffit de calculer 1/X avec RCP, et de multiplier le résultat par Y.

S'il n'y avait aucune instruction d'accès à la mémoire sur le processeur de la Geforce 3, la situation a changé depuis : les cartes graphiques récentes peuvent aller lire certaines données depuis la mémoire vidéo. Généralement, les shaders lisent des textures en mémoire vidéo, textures qui servent à colorier les pixels ou à configurer les calculs d'éclairage. Et la plupart des cartes graphiques suivant la Geforce 2 incorporait des instructions de lecture de texture. Les écritures, plus rares, sont venues après, afin de faciliter certaines techniques de rendu dont je ne parlerais pas ici.

Autre manque : les instructions de branchement. C'est un fait, ce processeur ne peut pas effectuer de branchements. À la place, il doit simuler ceux-ci en utilisant des instructions arithmétiques. C'est très complexe, et cela limite un peu les possibilités de programmation. À l'époque, ces branchements n'étaient pas utiles, sans compter que les environnements de programmation ne permettaient pas d'utiliser de branchements lors de l'écriture de shaders. De nos jours, les cartes graphiques récentes peuvent effectuer des branchements, ou du moins, des instructions similaires. On pourrait croire que l’absence de branchements pose problème, mais les concepteurs de processeur ont implémenté diverses solutions pour palier ce manque.

  • Certains processeurs utilisent des instructions à prédicats, des instructions "annulables" qui ne s’exécutent que si une condition est remplie.
  • D'autres instructions ne modifient un élément d'un vecteur que si celui-ci remplit une condition. Pour cela, le processeur de traitement de vertices contient un Vector Mask Register. Celui-ci permet de stocker des informations qui permettront de sélectionner certaines données et pas d'autres pour faire notre calcul. Il est mis à jour par des instructions de comparaison. Ce Vector Mask Register va stocker un bit pour chaque flottant présent dans le vecteur à traiter, bit qui indique s'l faut appliquer l'instruction sur ce flottant. Si ce bit est à 1, notre instruction doit s’exécuter sur la donnée associée à ce bit. Sinon, notre instruction ne doit pas la modifier. On peut ainsi traiter seulement une partie des registres stockant des vecteurs SIMD.
Vector mask register

Processeurs VLIW[modifier | modifier le wikicode]

Sur les processeurs VLIW, les instructions sont regroupées dans ce qu'on appelle des Bundles, des sortes de super-instructions. Les instructions d'un bundle peuvent s'exécuter en parallèle sur différentes unités de calcul, mais le bundle est chargé en une seule fois depuis la mémoire. Chaque instruction d'un faisceau doit expliciter quelle unité de calcul doit la prendre en charge. Vu que chaque instruction sera attribué à une unité de calcul différente, le compilateur peut se débrouiller pour que les instructions dans un bundle soient indépendantes. Mais il se peut que le compilateur ne puisse pas remplir tout le bundle avec des instructions indépendantes. Sur les anciens processeurs VLIW, les bundles étaient de taille fixe, ce qui forcait le compilateur à remplir d'éventuels vides avec des NOP (des instructions qui ne font rien), diminuant la densité de code. La majorité des processeurs VLIW récents utilise des bundles de longueur variable, supprimant ces NOP.

Dans la majorité des cas, ces unités VLIW sont capables de traiter deux instructions arithmétiques en parallèles : une qui sera appliquée aux couleurs R, G, et B, et une autre qui sera appliquée à la couleur de transparence. Cette possibilité s'appelle la co-issue.

Jeu de registres[modifier | modifier le wikicode]

Un processeur de shaders contient deux types de registres :

  • des registres généraux, qui peuvent mémoriser tout type de données ;
  • des registres d'entrée, qui réceptionnent les vertices ou pixels ;
  • des registres de sorties, dans lesquelles le processeur stocke ses résultats finaux ;
  • des registres qui servent à stocker des constantes.

Un processeur de shaders contient, en plus des registres généraux, des registres de constantes pour stocker les matrices servant aux différentes étapes de transformation ou d'éclairage. Ces constantes sont placées dans ces registres lors du chargement du vertex shader dans la mémoire vidéo : les constantes sont chargées un peu après. Toutefois, le vertex shader peut écrire dans ces registres, au prix d'une perte de performance particulièrement violente. Le choix du registre de constante à utiliser s'effectue en utilisant un registre d'adresse de constante. Celui-ci va permettre de préciser quel est le registre de constante à sélectionner dans une instruction. Une instruction peut ainsi lire une constante depuis les registres constants, et l'utiliser dans ses calculs.

Microarchitecture[modifier | modifier le wikicode]

Outre le jeu d’instruction, la conception interne (aussi appelée microarchitecture) des processeurs de shaders possède quelques particularités idiosyncratiques. Rien de bien déroutant pou qui a déjà étudié les architectures à parallélisme de données, mais quelques rappels ou explications ne peuvent pas faire de mal. Tout ce qu'il faut signaler est que les cartes graphiques sont des architectures massivement parallèles, à savoir qu'elles sont capables de faire un très grand nombre de calculs simultanés, durant le même cycle d'horloge. Il faut dire que chaque vertice ou pixel peut être traité indépendamment des autres, ce qui rend le traitement 3D fortement parallèle. Cela a quelques conséquences sur le nombre de processeurs et d'unités de calcul, mais cela influence aussi la hiérarchie mémoire. L'architecture doit être concue pour pouvoir effectuer u maximum de calculs en parallèle, quitte à simplifier fortement la puissance pour des calculs séquentiels (ceux qu'effectue un processeur). Les processeurs de shaders sont donc relativement simples, sans fioritures tant utiles pour du calculs séquentiels : pas d’exécution dans le désordre, de renommage de registres, et autres. La hiérarchie mémoire doit aussi gérer un grand nombre d'accès mémoire simultanés : qui dit plein d'instructions en parallèle qui travaillent sur des données indépendantes dit aussi plein de données indépendantes à lire ou écrire !

Un grand nombre d'unités de calcul SIMD[modifier | modifier le wikicode]

Ces processeurs disposent le plus souvent d'une flopée d'unités de calcul SIMD, à savoir qu'elles calculent des instructions SIMD complètes, chaque pixel étant traité en parallèle.

SIMD2

Pour profiter au mieux des opportunités de parallélisme, une carte graphique contient de nombreux processeurs, qui eux-même contiennent plusieurs unités de calcul. Vu le grand nombre d'unités de calcul, les autres circuits ne peuvent pas trop prendre de place. En conséquence, les unités de décodage et/ou de contrôle sont relativement simples, peu complexes. Leurs fonctionnalités sont limitées au strict minimum, avec cependant quelques optimisations sur les cartes graphiques récentes. Savoir combien de cœurs contient une carte graphique est cependant compliqué, les services marketing gardant un certain flou sur le sujet. Il n'est pas rare que ceux-ci appellent cœurs ou processeurs de simples unités de calcul, histoire de gonfler les chiffres. Et on peut généraliser à la majorité de la terminologie utilisée par les fabricants, que ce soit pour les termes warps processor, ou autre, qui ne sont pas aisés à interpréter. D'ordinaire, ce qui est appelé processeur de thread sur une carte graphique correspond en réalité à une unité de calcul.

Hiérarchie mémoire[modifier | modifier le wikicode]

La hiérarchie mémoire des GPUs est assez particulière, que ce soit au niveau des caches ou de la mémoire, parfois des registres. On y trouve souvent des caches dédiés aux textures ou aux vertices, et les GPUs récents contiennent aussi des caches L1 et L2 de faible taille.

Memory

Les caches[modifier | modifier le wikicode]

Les cartes graphiques sont censées avoir peu de caches. Les premières cartes graphiques n'avaient qu'un cache de texture et éventuellement un cache de vertices, avec un cache d’instruction. Cette situation a perduré durant très longtemps, l'usage de caches plus complexe n'étant pas vraiment utile sur les cartes graphiques. Ce n'est que par la suite, quand les GPU commencèrent à être utilisés pour du calcul généraliste (scientifique, notamment), que la situation changea. Les GPU utilisèrent alors de plus en plus de caches généralistes. La hiérarchie mémoire des GPU ressemblent de plus en plus à celle des CPU, du moins pour les caches. On y trouve toute une hiérarchie de caches, avec des caches L1, L2, L3, etc.

Les Local Store[modifier | modifier le wikicode]

En plus d'utiliser des caches, les processeurs de flux utilisent des local stores, des mémoires RAM intermédiaires entre la RAM principale et les caches/registres. Typiquement, chaque processeur de flux possède sa propre mémoire locale. Ces local stores peuvent être vus comme des caches, mais que le programmeur doit gérer manuellement. La faible capacité de ces mémoires, tout du moins comparé à la grande taille de la mémoire vidéo, les rend utile pour stocker temporairement des résultats de calcul "peu imposants". L'utilité principale est donc de réduire le trafic avec la mémoire centrale, les écritures de résultats temporaires étant redirigés vers les local stores.

Cuda5

Processeurs de flux[modifier | modifier le wikicode]

Beaucoup de processeurs graphiques actuels sont des processeurs de flux, aussi appelés stream processors. Ce sont des processeurs SIMD qui utilisent une hiérarchie de registres. Voici à quoi ressemble l'architecture d'un Stream Processor :

Stream processor

Les Streams Processors ont plusieurs bancs de registres. On trouve d'abord quelques Local Register File, directement connectés aux unités de calcul. Plus bas, ces Local Register Files sont reliés à un Register File plus gros, le Global Register File, lui-même relié à la mémoire. Ce Global Register File sert d'intermédiaire entre la mémoire RAM et le Local Register File. La différence entre ce Global Register File et un cache vient du fait que les caches sont souvent gérés par le matériel, tandis que ces Register Files sont gérés via des instructions machines. Le processeur dispose ainsi d'instructions pour transférer des données entre les Register Files ou entre ceux-ci et la mémoire. Leur gestion peut donc être déléguée au logiciel, qui saura les utiliser au mieux. Outre son rôle d'intermédiaire, le Global Register File sert à transférer des données entre les Local Register Files, où à stocker des données globales utilisées par des Clusters d'ALU différents. Les transferts de données entre la mémoire et le Global Register File ressemblent fortement à ceux qu'on trouve sur les processeurs vectoriels. Un Stream Processor possède quelques instructions capables de transférer des données entre ce Global Register File et la mémoire RAM. Et on trouve des instructions capables de travailler sur un grand nombre de données simultanées, des accès mémoires en Stride, en Scatter-Gather, etc.

Stream processor registers

On peut se demander pourquoi utiliser plusieurs couches de registres ? Le fait est que les Streams Processors disposent d'une grande quantité d'unités de calcul. Et cela peut facilement aller à plus d'une centaine ou d'un millier d'ALU ! Si on devait relier toutes cas unités de calcul à un gros Register File, celui-ci serait énorme, lent, et qui chaufferait beaucoup trop. Pour garder un Register Files rapide et pratique, on est obligé de limiter le nombre d'unités de calcul connectées dessus, ainsi que le nombre de registres contenus dans le Register File. La solution est donc de casser notre gros Register File en plusieurs plus petits, reliés à un Register File plus gros, capable de communiquer avec la mémoire. Ainsi, nos unités de calcul vont aller lire ou écrire dans un Local Register File très rapide.

Mitigation de la latence mémoire[modifier | modifier le wikicode]

Tous les pixels doivent accéder à une texture pour être coloriés, certains traitements devant être effectués ensuite par un pixel shader. Mais un accès à une texture, c'est long : une bonne centaine de cycles d'horloges lors d'un accès à une texture est un minimum si celle-ci est lue depuis la mémoire vidéo. Pour éviter que le processeur de shaders attende la mémoire, celui-ci dispose de techniques élaborées.

Une forme limitée d’exécution dans le désordre[modifier | modifier le wikicode]

L'unité de texture est située dans le processeur de shaders, à coté des unités de calcul. L'unité de texture peut fonctionner en parallèle des unités de calcul, comme toute unité d'accès mémoire. Ainsi, on peut poursuivre l’exécution du shader en parallèle de l'accès mémoire, à condition que les calculs soient indépendants de la donnée lue. Dans ces conditions, un shader peut masquer a latence de l'acccès mémoire en exécutant une grande quantité d'instructions à exécuter en parallèle : si un accès mémoire dure 200 cycles d'horloge, le processeur de shader doit disposer de 200 instructions à exécuter pour masquer totalement l'accès à la texture. De plus, le shader effectue souvent plusieurs accès mémoire assez rapprochés : si l'unité de texture ne peut pas gérer plusieurs lectures en parallèle, la lecture la plus récente est mise en attente et bloque toutes les instructions qui la suivent.

Multi-threading matériel[modifier | modifier le wikicode]

Trouver suffisamment d’instructions indépendantes d'une lecture dans un shader n'est donc pas une chose facile. Les améliorations au niveau du compilateur de shaders des drivers peuvent aider, mais la marge est vraiment limitée. Pour trouver des instructions indépendantes d'une lecture en mémoire, le mieux est encore d'aller chercher dans d'autres shaders… Sans la technique qui va suivre, chaque shader correspond à un programme qui s’exécute sur toute une image. Avec les techniques de multi-threading matériel, chaque shader est dupliqué en plusieurs copies indépendantes, des threads, qui traitent chacun un morceau de l'image. Un processeur de shader peut traiter plusieurs threads, et répartir les instructions de ces threads sur l'unité de calcul suivant les besoins : si un thread attend la mémoire, il laisse l'unité de calcul libre pour un autre.

SIMT[modifier | modifier le wikicode]

Les processeurs plus récents fonctionnent comme des processeurs SIMD au niveau de l'unité de calcul, mais ce fonctionnement est masqué au niveau du jeu d'instruction. Ces processeurs poussent la logique des threads jusqu'au bout : chaque instance de shader (thread) ne manipule qu'un seul pixel ou vertex. Ces threads sont rassemblés en groupes de 16 à 32 threads qui exécutent la même instruction, en même temps, mais sur des pixels différents. En clair, ces processeurs vont découvrir à l’exécution qu'ils peuvent exécuter la même instruction sur des pixels différents, et fusionner leurs instructions en instructions vectorielles. L'instruction vectorielle née de cette fusion est appelée un warp. On parle de Single Instruction Multiple Threads.

Chaque thread se voit attribuer un Program Counter, des registres, et un identifiant qui permet de l'identifier parmi tous les autres. Un circuit spécialisé fusionne les pixels des threads en vecteurs qu'il distribue aux unités de calcul. Sur certaines cartes graphiques récentes, le processeur peut démarrer l'exécution de plusieurs warps à la fois. Il faut noter que si un branchement ne donne pas le même résultat dans différents threads d'un même warp, le processeur se charge d'effectuer la prédication en interne : il utilise quelque chose qui fait le même travail que des instructions de prédication qui utilisent vector mask register. Dans ce cas, chaque thread est traité un par un par l'unité de calcul. Ce mécanisme se base sur une pile matérielle qui mémorise les threads à exécuter, dans un certain ordre.