Les cartes graphiques/Les processeurs de shaders

Un livre de Wikilivres.
Aller à la navigation Aller à 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 appelés des shaders. Les shaders sont écrits dans un langage de haut-niveau, le HLSL pour les shaders Direct X et le GLSL pour les shaders Open Gl, puis sont ensuite traduit (compilés) en instructions machines à destination des processeurs de shaders. Au début, ces langages et la carte graphique supportaient uniquement des opérations simples. Mais 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 shaders étaient dédiés aux traitement de la géométrie, et étaient appelés des vertex shaders. Par la suite, l'étape de traitement des pixels est elle aussi devenue programmable, les pixels shaders ont fait leur apparition. Puis d'autres formes de shaders sont apparues, pour effectuer des calculs géométriques complexes ou des calculs non-graphiques.

Le jeu d'instruction des processeurs de shaders[modifier | modifier le wikicode]

Les processeurs de shaders peuvent effectuer le même calcul sur plusieurs vertices ou plusieurs pixels à la fois. On dit que ce sont des processeurs parallèles, à savoir qu'ils peuvent faire plusieurs calculs en parallèle dans des unités de calcul séparées. Suivant la carte graphique, on peut les classer en deux types, suivant la manière dont ils exécutent des instructions en parallèle : les processeurs SIMD et les processeurs VLIW.

Avant d'expliquer à quoi correspondent ces deux termes, sachez juste que l'usage de processeurs VLIW dans les cartes graphiques est anecdotique. 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. Pour simplifier, cette technique permettait d’exécuter deux instructions arithmétiques en même temps, en parallèle : une qui sera appliquée aux couleurs R, G, et B, et une autre qui sera appliquée à la couleur de transparence.. Si cela peut sembler intéressant sur le papier, cela complexifiait fortement le processeur de shader, ainsi que la traduction à la volée des shaders en instructions machine. Raison pour laquelle cette technique a été abandonnée. 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é).

Les instructions SIMD[modifier | modifier le wikicode]

Les instructions des processeurs SIMD manipulent plusieurs nombres en même temps. Elles manipulent plus précisémment 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 SIMD traite chaque 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. 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 surtout des instructions arithmétiques : multiplications, additions, exponentielles, logarithmes, racines carrées, etc. On remarque que 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.

À côté, 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. Il n'y avait aucune instruction d'accès à la mémoire sur le processeur de la Geforce 3. Mais la situation a changé dans les cartes graphiques ultérieures, qui 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. Les cartes graphiques récentes peuvent aller lire ou écrire certaines données depuis la mémoire vidéo à des adresses arbitraires. Les modes d'adressages sont aussi devenus plus complexes.

Autre manque de la Geforce 3 : les instructions de branchement. À 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, mais elles disposent aussi de techniques permettant de s'en passer facilement grâce à un registre appelé le 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'il 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

Les cartes graphiques anciennes 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. Depuis DirectX 10, ce n'est plus le cas : le jeu d'instructions a été unifié entre les vertex shaders et les pixels shaders et un processeur de shader peut effectuer aussi bien des calculs géométriques que des traitements de pixels.

Les registres des processeurs de shaders[modifier | modifier le wikicode]

Un processeur de shaders contient beaucoup de registres, sans quoi il ne pourrait pas faire son travail efficacement. Les anciennes cartes graphiques avaient beaucoup de registres spécialisés, c'est à dire que chaque registre avait une fonction bien définie et ne pouvait stocker qu'un type de donnée bien précise. Mais avec l'évolution de Direct X et d'Open GL, les registres sont devenues de moins en moins spécialisés et sont devenues des registres généraux, interchangeables, capables de stocker des données arbitraires. De nos jours, les registres spécialisés sont devenus anecdotique, car leur a perdu de son intérêt avec l'unification des shaders : la plupart des registres spécialisés n'avaient de sens que pour les vertices et rien d'autre.

Les anciennes cartes graphiques : beaucoup de registres spécialisés[modifier | modifier le wikicode]

Sur les processeurs de vertices des anciennes cartes 3D, il y avait bien quelques registres généraux, sans fonction préétablie, mais ils étaient secondés par un grand nombre de registres spécialisés assez nombreux. Certains étaient spécialisés dans le stockage des vertices, d'autres dans le stockage des résultats de calculs, d'autres dans le stockage de constantes, etc. Les registres spécialisés des processeurs de vertices peuvent se classer en plusieurs types :

  • 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 sortie, dans lesquelles le processeur stocke ses résultats finaux ;
  • des registres de constantes qui, comme leur nom l'indique, servent à stocker des constantes.

Un processeur de vertices contenait, 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.

Architecture d'un processeur de shaders avec accès aux textures.

Les cartes graphiques modernes : une hiérarchie de registres généralistes[modifier | modifier le wikicode]

De nos jours, les processeurs de shaders utilisent une hiérarchie de registres. On trouve d'abord quelques bancs de registres locaux (Local Register File), directement connectés aux unités de calcul. Ces derniers sont reliés à un banc de registres plus gros, le banc de registre global, qui sert d'intermédiaire entre la mémoire RAM et les bancs de registres locaux. La différence entre les bancs de registres locaux/globaux et un cache vient du fait que les caches sont souvent gérés par le matériel, tandis que ces bancs de registres sont gérés via des instructions machines. Le processeur dispose d'instructions pour transférer des données entre les bancs de registres 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 banc de registre global sert à transférer des données entre les bancs de registres locaux, 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 processeur de flux 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.

Registres d'un Stream processor.

On peut se demander pourquoi utiliser plusieurs couches de registres ? Le fait est que les processeurs de flux 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 banc de registres, celui-ci serait énorme, lent, et qui chaufferait beaucoup trop. Pour garder un banc de registres 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 banc de registres. La solution est donc de casser notre gros banc de registres en plusieurs plus petits, reliés à un banc de registres plus gros, capable de communiquer avec la mémoire. Ainsi, nos unités de calcul vont aller lire ou écrire dans un banc de registres local très rapide.

La microarchitecture des processeurs de shaders[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.

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.

Ce schéma illustre l'architecture d'un GPU en utilisant la terminologie NVIDIA. Comme on le voit, la carte graphique contient plusieurs cœurs de processeur distincts. Chacun d'entre eux contient plusieurs unités de calcul généralistes, appelées processeurs de threads, qui s'occupent de calculs simples (en bleu). D'autres calculs plus complexes sont pris en charge par une unité de calcul spécialisée (en rouge). Ces cœurs sont alimentés en instructions par le processeur de commandes, ici appelé Thread Execution Control Unit, qui répartit les différents shaders sur chaque cœur. Enfin, on voit que chaque cœur a accès à une mémoire locale dédiée, en plus d'une mémoire vidéo partagée entre tous les cœurs.

Un grand nombre de processeurs et d'unités de calculs[modifier | modifier le wikicode]

Comparaison du nombre de processeurs et de cœurs entre CPU et GPU.

Pour profiter au mieux des opportunités de parallélisme, 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. Il n'est pas rare que ceux-ci appellent cœurs ou processeurs, ce qui correspond en réalité à une unité de calcul d'un processeur normal, sans doute 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.

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.

Des unités de calcul autrefois spécialisée, puis devenus banalisées[modifier | modifier le wikicode]

Même les premiers processeurs de shaders disposaient de plusieurs unités de calculs séparées, capables de faire des calculs en parallèle. Un bon exemple est le processeur de vertices de la Geforce 6800, illustré ci-dessous. On voit que le processeur contient trois unités de calculs séparées : une unité généraliste de calcul sur les vertices, une unité pour les opérations mathématiques complexes (division, racine carrée, racine carrée inverse, autres) et enfin une unité pour le calcul Multiply-And-Add (une multiplication suivie d'une addition, opération très courante en 3D). On voit que les unités de calculs sont assez spécialisées, avec une ou plusieurs unités généralistes, secondée par des unités capables de faire des calculs spécialisés.

Processeur de shader (vertex shader) d'une GeForce 6800. On voit clairement que celui-ci contient, outre les traditionnelles unités de calcul et registres temporaires, un "cache" d'instructions, des registres d'entrée et de sortie, ainsi que des registres de constante.

Cela n'a pas changé avec l'unification des shaders, si ce n'est que les unités de calcul ne sont pas aussi spécialisées que dans l'exemple précédent. Les GPU modernes disposent d'une flopée d'unités de calcul SIMD identiques, à savoir qu'elles calculent des instructions SIMD complètes, chaque pixel étant traité en parallèle. De nos jours, les unités de calculs dédiées à des opérations bien précises existent encore, mais sont plus rares.

Unités de calculs SIMD.

La 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, à côté 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’accè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 d'assez d'instructions à exécuter pour combler ces 200 cycles d'horloge. 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.

Le multithreading 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.