Les cartes graphiques/Les processeurs de shaders

Les shaders sont des programmes informatiques exécutés par la carte graphique, et plus précisément par des processeurs de shaders. Dans ce chapitre, nous allons voir comment fonctionnent les processeurs de shaders. Nous allons voir que ceux-ci ne sont pas très différents des processeurs que l'on trouve dans les ordinateurs normaux, du moins dans les grandes lignes. Ce sont des processeurs séquentiels, qui exécutent des instructions les unes après les autres, ils ont des instructions machines, des modes d'adressage, un assembleur, des registres et tout ce qui fait qu'un processeur est un processeur. Néanmoins, il y a une différence de taille : ce sont des processeurs adaptés pour effectuer un grand nombre de calculs en parallèle.

Le jeu d'instruction des processeurs de shaders modifier

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 semble intéressant sur le papier, cela complexifie 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

Les instructions des processeurs SIMD manipulent plusieurs nombres en même temps. Elles manipulent plus précisément 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

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. le Vector Mask Register stocke 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 registres des processeurs de shaders modifier

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

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

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

Outre le jeu d’instruction, la conception interne (aussi appelée microarchitecture) des processeurs de shaders possède quelques particularités idiosyncratiques. Les cartes graphiques modernes, programmables, sont des architectures massivement parallèles, à savoir qu'elles sont capables d'effectuer un grand nombre de calculs indépendants en même temps, quitte à réduire la puissance pour des calculs séquentiels (ceux qu'effectue un processeur). 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 plus, le parallélisme utilisé est du parallélisme de données, à savoir qu'on exécute le même programme sur des données différentes, chaque donnée étant traitée en parallèle des autres. La raison à cela est très importante et il faut la retenir :

L’exécution d'un shader génère un grand nombre d'instances de ce shader, chacune traitant un paquet de pixels/sommets différent.

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 ont plusieurs cœurs, chaque coeur est capable 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. 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.

 
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

 
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 variées et spécialisées modifier

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. Mais ces unités étaient conçues pour le rendu 3D, les opérations qu'elles faisaient étés prévues pour cet objectif. Les processeurs principaux (CPU) d'un ordinateur sont capables de faire beaucoup de calculs arithmétiques simples, comme des additions, des multiplications, des opérations bit-à-bit, éventuellement des divisions, etc. Mais il est rare qu'ils soient capables de faire des opérations plus complexes, comme des calculs trigonométriques, des exponentielles, des logarithmes, des racines carrées ou racines carrées inverse, etc. De tels calculs sont rares dans les programmes exécutables hors-rendu 3D ou traitement d'image et créer des instructions spécifiques ne vaudrait pas le coup, alors que les calculs arithmétiques simples y sont légion.

Mais pour les unités de calculs des processeurs de shaders, ce n'est pas le cas. Les instructions de calcul arithmétiques sont nécessaires, mais le gros du travail demande d'effectuer des opérations complexes. Il faut dire que le rendu 3D demande pas mal de calculs trigonométriques, de produits scalaires ou d'autres opérations. Aussi, les processeurs de shaders disposent souvent d'une unité de calcul spécialisée dans les calculs complexes (exponentielle/logarithme, racine carrée, racine carrée inverse, autres), parfois de plusieurs. De plus, on trouve souvent une unité de calcul spécialisée dans l’opération Multiply-And-Add (une multiplication suivie d'une addition, opération très courante en 3D, notamment dans le calcul de produits scalaires). Un bon exemple est le processeur de vertex shader de la Geforce 6800, illustré ci-dessous. On voit que le processeur contient une unité d'accès mémoire pour lire les textures, avec deux unités de calcul : une unité pour les opérations mathématiques complexes et une unité pour le calcul Multiply-And-Add. 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.
 
Schéma de la micro-architecture d'un processeur sur la micro-architecture Fermi de NVIDIA.

De nos jours, les unités de calculs dédiées à des opérations bien précises existent encore, même dans les architectures récentes. Pour vous en donner un exemple, je vais prendre l'architecture Fermi de NVIDIA, utilisée dans les cartes graphiques de la marque sorties en 2010. Les cartes graphiques de cette marque sont composées de plusieurs processeurs de shaders. L'intérieur d'un de ces processeur est illustrée ci-contre, avec les termes de nomenclature utilisée par NVIDIA.

Les différentes mémoires inclues dans le processeur sont en bleu. Elles regroupent un cache d'instruction, un banc de registres (register file) qui contient les registres du processeur, et divers caches (en bas) reliés au reste du processeur par un réseau d’interconnexion (interconnect network). En orange se trouve les circuits de contrôle, qui ont pour but de décoder et de répartir les instructions sur les unités de calculs plus bas. Ils regroupent l'unité de décodage des instructions (non-représentée), des warp scheduler et les unités de dispatch qui s'occupent de la répartition des calculs sur les unités de calcul. Enfin, en vert, on a diverses unités de calcul. Les unités core ne sont pas des cœurs de processeurs, comme la terminologie utilisée par NVIDIA peut le faire croire, mais des unités de calcul arithmétiques simples, capables de faire additions, multiplications, et quelques autres calculs du genre. Les unités LD/ST sont les unités pour l'accès à la mémoire et elles sont capables d'accèder à des textures et de les filtrer. Enfin, les unités SFU sont des unités de calculs spécialisées dans les calculs complexes, trigonométriques ou autres.

La mitigation de la latence mémoire modifier

Un accès à une texture, c'est long : si celle-ci est lue depuis la mémoire vidéo, le temps d'attente est d'une bonne centaine de cycles d'horloges. Pour limiter la casse, les unités de texture incorporent un cache de texture, mais cela ne suffit pas toujours à alimenter les processeurs de shaders en données. S'ils n'utilisaient pas d'autres optimisations, les processeurs de shader devraient attendre plusieurs centaines ou dizaines de cycles d'horloge à ne rien faire, en attendant les données de la texture. Fort heureusement, les processeurs de shaders disposent d'optimisations pour remplir ces temps d'attente avec des calculs indépendants de la texture lue.

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

L'unité de texture peut fonctionner en parallèle des unités de calcul, comme toute unité d'accès mémoire. Cela permet de continuer l’exécution du shader en cours, celui qui a lancé la lecture de texture, en parallèle de l'accès mémoire.

Évidemment, cela ne fonctionne que si la lecture est suivie de calculs qui n'ont pas besoin de la donnée lue. Les shaders doivent être conçus pour que cette technique marche, en faisant en sorte que les instructions qui suivent la lecture de texture sont bien indépendante de celle-ci. Idéalement, si un accès mémoire dure 200 cycles d'horloge, il faut que le shader exécute des instructions indépendantes de la lecture durant 200 cycles d'horloge. Dans la réalité, les résultats seront moins bons. On réussira peut-être à exécuter une dizaine d'instruction pendant la lecture, mais ce gain est bon à prendre. Dans tous les cas, le processeur de shader peut masquer totalement ou partiellement la latence de l’accès mémoire.

Les accès mémoire non-bloquants modifier

Outre le manque d'instructions indépendantes de la lecture, un autre problème réduit les performances de cette technique. Un shader effectue souvent plusieurs accès mémoire assez rapprochés. Et un problème survient quand une lecture est en cours et qu'un autre accès mémoire a lieu.

Sur les anciennes cartes graphiques, l'unité de texture ne peut gérer qu'un seul accès mémoire à la fois. En conséquence, quand une instruction d'accès mémoire a lieu, elle doit attendre que la lecture précédente se termine. Ce faisant, elle bloque toutes les instructions qui la suivent, le shader est mis en pause. C'était un problème sur les cartes graphiques anciennes, mais ce n'est plus tellement le cas de nos jours. Les unités de texture actuelles peuvent effectuer plusieurs accès mémoire en parallèle. Mais elles ont un nombre limité d'accès mémoire simultanés et il arrive rarement qu'un shader dépasse ce nombre, ce qui entraine alors une situation de blocage.

Vous vous demandez comment font les unités de texture modernes pour effectuer plusieurs accès mémoire à la fois ? La réponse est malheureusement entre les mains des concepteurs de cartes graphiques et peu de détails ont fuité. Mais une hypothèse raisonnable est qu'elles effectuent un accès mémoire à la fois, les autres accès mémoire étant mis en attente. Les accès mémoire s’exécutent vraisemblablement dans l'ordre du shader, il n'y a pas de réorganisation des accès mémoire ou d’exécution dans le désordre des accès mémoire. La manière la plus simple d'implémenter des accès mémoire multiples est de les mettre en attente dans une petite mémoire FIFO.

 
Accès mémoire simultanés.

Le multithreading matériel modifier

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.