Licence CC BY

Les processeurs de Shaders

Publié :
Auteurs :

Catégorie :

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 de traitement de la géométrie étaient autrefois câblées : elles ne pouvaient effectuer qu'un éclairage de type Phong, rien de plus. Alors certes, il était possible de configurer certains paramètres pour obtenir un éclairage proche de celui voulu, mais le type d'éclairage restait le même.

Par la suite, les unités de traitement de la géométrie sont devenues des unités programmables. Par programmable, on veut dire qu'il est possible de spécifier leur comportement via un programme informatique. Cela permet une grande flexibilité : changer le comportement ne nécessite pas de re-câbler tout le circuit (ce qui est souvent impossible) : il suffit simplement ce changer la suite d'instructions à exécuter. Nos unités de traitement de la géométrie deviennent donc des processeurs indépendants, capable d'effectuer un certain nombre d'instructions sur les données de nos Vertices.

Ces processeurs sont donc capable d’exécuter des programmes sur des vertices. Ces programmes sont appelés des Vertex Shaders. Ils sont souvent écrits dans un langage de haut-niveau, le HLSL ou le GLSL, et sont traduits (compilés) 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, et le matériel en a fait autant.

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.

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.

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.

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 qui servent à stocker des constantes.

Ces derniers permettent de stocker les matrices servant aux différentes étapes de transformation, à stocker les positions des sources de lumière pour l'éclairage, etc. 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 de la constante à utiliser dans une instruction s'effectue en utilisant un registre : le 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.

Jeux d'instruction

Sur tous les processeurs de traitement de vertices, il est possible de traiter plusieurs morceaux de vertices à la fois. Pour cela, les processeurs de traitement de vertices utilisent :

  • soit des instructions spécialisées, qui peuvent manipuler un grand nombre de données simultanément ;
  • soit incluent un grand nombre d'unités de calcul qui fonctionnent en parallèle.

Il existe deux techniques pour cela :

  • les processeurs SIMD ;
  • les processeurs VLIW ;
  • les processeurs de flux.

Processeurs SIMD

Les instructions des processeurs SIMD sont des instructions vectorielles : elles travaillent sur des vecteurs. Ces vecteurs contiennent plusieurs nombres entiers ou nombres flottants placés les uns à côté des autres, et ont 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.

Geforce 3

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. On y trouve des multiplications, des additions, des exponentielles, des logarithmes, des 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.

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 remarque qu'il n'y a aucune instruction d'accès à la mémoire. Notre processeur ne peut pas aller chercher d’informations dans la mémoire vidéo. Le processeur de la Geforce 3 doit se contenter de ses registres. Depuis, la situation a changé : les cartes graphiques récentes peuvent aller lire certaines données depuis la mémoire vidéo.

Instructions à prédicats

Les instructions vectorielles sont très utiles quand on doit effectuer un traitement identique sur un ensemble de données identiques. Mais dès que le traitement à effectuer sur nos données varie suivant le résultat d'un test ou d'un branchement, les choses se gâtent. Mine de rien, avec une instruction vectorielle, on est obligé de calculer et de modifier tous les éléments d'un paquet : il est impossible de zapper certains éléments d'un paquet dans certaines conditions. Par exemple, imaginons que je veuille seulement additionner les éléments d'un paquet ensemble s'ils sont positifs : je ne peux pas le faire avec une instruction vectorielle "normale". Du moins, pas sans aide.

Pour résoudre ce problème, certains processeurs utilisent des instructions à prédicats. Pour faire simple, ces instructions sont des instructions "annulables". Elle ne modifient un élément d'un vecteur que si celui-ci remplit une condition. Pour cela, notre 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 des bits pour chaque flottant présent dans le vecteur à traiter. 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.

Processeur VLIW

Autre solution : faire de nos Streams Processors des processeurs VLIW. Sur ces processeurs VLIW, nos instructions sont regroupées dans ce qu'on appelle des Bundles, des sortes de super-instructions. Ces bundles sont découpés en slots, en morceaux de taille bien précise, dans lesquels il va venir placer les instructions élémentaires à faire exécuter.

Instruction VLIW à 3 slots
Slot 1 Slot 2 Slot 3
Addition Multiplication Décalage à gauche

Chaque slot sera attribué à une unité de calcul bien précise. Par exemple, le premier slot sera attribué à la première ALU, la second à une autre ALU, le troisième à la FPU, etc. Ainsi, l'unité de calcul exécutant l'instruction sera précisée via la place de l'instruction élémentaire, le slot dans lequel elle se trouve.

Qui plus est, vu que chaque slot sera attribué à une unité de calcul différente, le compilateur peut se débrouiller pour que chaque instruction dans un bundle soit indépendante de toutes les autres instructions dans ce bundle. Lorsqu'on exécute un bundle, il sera décomposé par le séquenceur en petites instructions élémentaires qui seront chacune attribuée à l'unité de calcul précisée par le slot qu'elles occupent. Pour simplifier la tâche du décodage, on fait en sorte que chaque slot ait une taille fixe.

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.

Streams processors

De nos jours, les processeurs de shaders sont ce qu'on appelle des Streams Processors, des processeurs SIMD qui utilisent plusieurs "couches" de registres.

Hiérarchie de registres

On trouve d'abord les Local Register Files, directement connectés aux unités de calcul : c'estlà que les unités de calcul vont aller chercher les données à manipuler. 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.

Hiérarchie de registres des *Streams processors*

Le Global Register File va servir d'intermédiaire entre la mémoire RAM et le Local Register File, un peu comme une mémoire cache. 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 par le logiciel (le programmeur) : le processeur dispose d'instructions pour transférer des données entre les Register Files ou entre ceux-ci et la mémoire.

Le Global Register File va servir à stocker un ou plusieurs Threads destinés à être traités par notre Stream Processor. Il peut aussi servir à 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. Quand à nos Local Register Files, ils vont servir à stocker des morceaux de Threads en cours de traitement : tous les résultats temporaires vont aller dans ce Local Register File, afin d'être lus ou écrits le plus rapidement possible.

Pourquoi trouve-t-on plusieurs couches de registres ? Le fait est que les Streams Processors disposent de plusieurs centaines d'unités de calcul. Or, pour garder un Register File rapide et pratique, on est obligé de limiter le nombre d'unités de calcul connectées dessus, ainsi que le nombre de registres. 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.

Architecture d'un GPU

Sur ces processeurs, des programmes, nommés Kernels, sont appliqués entièrement à un tableau de donnée que l'on appelle un Stream. Dans nos cartes graphiques actuelles, ce Stream est découpé en morceaux qui seront chacun traités sur un Stream Processor. Chacun de ces morceaux est appelé un Thread. Vous remarquerez que le terme Thread est ici utilisé dans un sens différent de celui utilisé précédemment. Faites attention !

Le découpage du Stream en Threads se fait à l’exécution. En clair : on envoie à notre carte 3D des informations sur le tableau à manipuler et celle-ci se débrouille toute seule pour le découper en morceau et les répartir sur les processeurs disponibles.

Un GPU actuel est souvent composé de plusieurs de ces Streams Processors, placés ensembles sur une même puce, avec quelques autres circuits annexes, utilisés dans les taches de rendu 3D. Ces Streams Processors sont alors pilotés par un gros micro-contrôleur qui se charge de découper le Stream à traiter en Threads, avant de les répartir sur les différents Streams Processors de la puce.

Microarchitecture

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.

Autrefois, la séparation entre unités de texture et unités de vertice était motivée par un argument simple : les unités de vertice n’accédaient jamais à la mémoire, contrairement aux unités de traitement de pixels qui doivent accéder aux textures.

Une forme limitée d’exécution dans le désordre

L'unité de texture est située dans le processeur de shaders, avec toutes les autres unités de calcul. Ceci dit, les processeurs de shaders ont une particularité : l'unité de texture peut fonctionner en parallèle des autres unités. 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 doit avoir une grande quantité d'instructions à exécuter : 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

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 programmes indépendants, des 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

Les cartes graphiques récentes fonctionnent un tout petit peu différemment de ce qu'on a vu jusqu’à présent : ils fonctionnent comme des processeurs SIMD au niveau de l'unité de calcul, mais ce fonctionnement interne est masqué au niveau du jeu d'instruction.

Ces processeurs poussent la logique des threads jusqu'au bout : chaque 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 ces instructions en une seule instruction vectorielle : on parle de SIMT.

Chaque threads 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. L'instruction vectorielle née de la fusion de plusieurs threads est appelée un warp. 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.