Les cartes graphiques/La microarchitecture des processeurs de shaders
La conception interne (aussi appelée microarchitecture) des processeurs de shaders possède quelques particularités idiosyncratiques. La microarchitecture des processeurs de shaders est 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.

Les unités de calcul d'un processeur de shader SIMD
[modifier | modifier le wikicode]Pour rappel, un processeur de shader supporte plusieurs types d'instructions. Au minimum, il supporte des instructions SIMD. Mais il peut aussi gérer des instructions scalaires, à savoir qu'elles travaillent sur des entiers/flottants isolés, non-regroupés dans un vecteur SIMD. Typiquement, il y a plusieurs types d'instructions scalaires : les calculs entiers, les calculs flottants simples, les calculs flottants complexes dit transcendantaux (calculs trigonométriques, des exponentielles, des logarithmes, des racines carrées ou racines carrées inverse).
Et ces différentes instructions ont toutes des unités de calcul dédiées, ce qui fait qu'un processeur de shader contient un grand nombre d'unités de calcul très différentes. Le cœur est une unité de calcul SIMD, qui se charge des instructions SIMD. Mais d'autres unités de calcul sont présentes, surtout sur les architectures modernes. Il n'est pas rare de trouver une unité de calcul entière, ou des unités de calcul flottantes spécialisées pour les opérations transcendantales.
Les unités de calcul SIMD
[modifier | modifier le wikicode]
Un processeur de shader incorpore une unité de calcul SIMD, qui effectue plusieurs calculs en parallèle. Elle regroupe plusieurs ALU flottantes regroupées ensemble et avec quelques circuits pour gérer les débordements et d'autres situations. En théorie, une unité de calcul SIMD regroupe autant d'unité de calcul qu'il y a d'entiers/flottants dans un vecteur. Par exemple, pour additionner deux vecteurs contenant chacun 16 flottants, il faut utilise 16 additionneurs flottants. Ce qui fait qu'une opération sur un vecteur est traité en une seule fois, en un cycle d'horloge. Une contrainte importante est que toutes les sous-ALU effectuent la même opération : ce ne sont pas des ALU séparées qu'on peut commander indépendamment, mais une seule ALU regroupant des circuits de calcul distinct.
Le cout en circuit est d'autant plus grand que les vecteurs sont longs et le cout est approximativement proportionnel à la taille des vecteurs. Entre des vecteurs de 128 et 256 bits, l'unité de calcul utilisera globalement deux fois plus de circuits avec 256 bits qu'avec 128. Même chose pour les registres, mais c'est là un cout commun à toutes les architectures.
Il y a quelques unités de calcul SIMD où le calcul se fait en deux fois, car on n'a que la moitié des unités de calcul. Par exemple, pour un vecteur de 32 flottants, on peut utiliser 16 unités de calcul, mais le temps de calcul se fait en deux cycles d'horloge. Les opérations sur les vecteurs sont donc faites en deux fois : une première passe pour les 16 premiers éléments, une seconde passe pour les 16 restants. L'implémentation demande cependant qu'une instruction de calcul soit décodée en deux micro-opérations. Par exemple, une instruction SIMD sur des vecteurs de 32 éléments est exécutée par deux micro-instructions travaillant sur des vecteurs de 16 éléments. On économise ainsi pas mal de circuits, mais cela se fait au détriment de la performance globale.
L'avantage est que cela se marie bien avec l'abandon des opérations pour lesquelles masques dont tous les bits sont à 0. Par exemple, prenons une instruction travaillant sur 16 flottants, exécutée en deux fois sur 8 flottants. Si le masque dit que les 8 premières opérations ne sont pas à exécuter, alors l'ALU ne fera que le calcul des 8 derniers flottants. Pour cela, le décodeur doit lire le registre de masque lors du décodage pour éliminer une micro-instruction si besoin, voire les deux si le masque est coopératif.
Plusieurs unités SIMD, liées au format des données
[modifier | modifier le wikicode]Il faut préciser qu'il y a une séparation entre unités SIMD flottantes simple et double précision. Pour le dire plus clairement, il y a des unités SIMD pour les flottants 32 bits, d'autres pour les flottants 64 bits, et même d'autres pour les flottants 16 bits. Les flottants 64 bits sont utilisés dans les applications GPGPU, les flottants 16 et 32 bits le sont dans le rendu 3D, et les flottants 16 bits pour tout ce qui lié à l'IA. Malheureusement, on doit utiliser des ALU flottantes séparées pour chaque taille, le format des flottants n'aidant pas.
Depuis plus d'une décennie, les cartes graphiques ont des unités SIMD à la fois pour les calculs entiers et flottants. Elles sont censées être séparées. Pour NVIDIA, avant l'architecture Turing, les unités SIMD entières et flottantes sont décrites comme séparées dans leurs white papers, avec des unités INT32 et FLOAT32 séparées, combinées à d'autres unités de calcul. L'architecture Volta a notamment des unités INT32, FLOAT32 et FLOAT64 séparées. A partir de l'architecture Ampere, il semblerait que les unités SIMD soient devenues capables de faire à la fois des calculs flottants et entiers, pour la moitié d'entre elles. Mais il se pourrait simplement qu'elles soient physiquement séparées, mais reliées aux reste du processeur de manière à ne pas être utilisables en même temps.
Il est cependant possible que sur d'anciennes architectures, les unités entières et flottantes partagent des circuits, notamment pour ce qui est de la multiplication. En effet, une unité de calcul flottante contient des circuits pour faire des calculs entiers pour additionner/multiplier les mantisses et les exposants. Il est possible d'utiliser ce circuit Un exemple est le cas de l'architecture GT200 d'NVIDIA, sur laquelle les "pseudo-ALU" entières SIMD étaient limitées à des multiplications d'opérandes 24 bits, ce qui correspond à la taille d'une mantisse d'un flottant 32 bits. Le design exact des ALU n'est pas connu.
Les unités de calcul scalaires
[modifier | modifier le wikicode]Les GPU modernes incorporent une unité de calcul entière scalaire, séparée de l'unité de calcul SIMD. Elle gère des calculs scalaires, à savoir qu'elle ne travaille pas sur des vecteurs. Elle gère divers calculs, comme des additions, soustractions, comparaisons, opérations bit à bit, etc. Elle exécute les instructions de calcul entière sur des nombres entiers isolés, de plus en plus fréquentes dans les shaders.
Elle est parfois accompagnée d'une unité de calcul pour les branchements. Par branchements, on veut parler des vrais branchements similaires à ceux des CPU, qui effectuent des tests sur des entiers et effectuent des branchements conditionnels. Ils n'ont rien à voir avec les instructions à prédicat qui elles sont spécifiques à l'unité de calcul vectorielles. Ce sont des instructions séparées, totalement distinctes
Les processeurs de shaders incorporent aussi une unité de calcul flottante scalaire, utilisée pour faire des calculs sur des flottants isolés. L'unité de calcul gère généralement des calculs simples, comme les additions, soustractions, multiplications et divisions. Il s'agit typiquement d'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), qui ne gère pas la division.
L'unité de calcul flottante est souvent accompagnée d'une unité de calcul spécialisée qui gère les calculs transcendantaux, avec une gestion des calculs trigonométriques, de produits scalaires ou d'autres opérations. Elle porte le nom d'unité de calcul spéciale (Special Function Unit), ou encore d'unité de calcul transcendantale, et elle a d'autres appellations. Elle est composée de circuits de calculs accompagnés par une table contenant des constantes nécessaires pour faire les calculs.
Rappelons que les registres SIMD et les registres scalaires sont séparés et ne sont pas adressés par les mêmes instructions. Les registres scalaires sont placés dans un banc de registre physiquement séparé du banc de registres SIMD. Le banc de registre scalaire est relié à sa propre ALU scalaire, il y a vraiment une séparation physique entre registres scalaires et SIMD. Il existe cependant un système d'interconnexion qui permet d'envoyer un scalaire aux unités SIMD, ce qui est utile pour les opérations de produits scalaire ou autres.
L'unité de texture/lecture/écriture
[modifier | modifier le wikicode]L'unité d'accès mémoire s'occupe des lectures et écriture en général, et elle prend en charge les accès aux textures, le filtrage de textures et tout un tas de fonctionnalités liées aux textures. La seule différence entre un accès aux textures et une lecture/écriture en mémoire est que les circuits de filtrage de texture sont contournés dans une lecture/écriture normale. Dans ce qui suit, nous allons l'appeler l'unité de texture par souci de simplification.
La gestion des dépendances de données
[modifier | modifier le wikicode]Un processeur de shaders SIMD contient donc beaucoup d'unité de calcul, qui peuvent en théorie fonctionner en parallèle. Il est en théorie possible d'exécuter des instructions séparés dans des unités de calcul séparées. Par exemple, reprenons l'exemple de l'unité de vertices de la Geforce 6800, mentionné au-dessus. Elle dispose d'une unité de calcul SIMD MAD, et d'une unité de texture, ainsi que d'une unité de calcul scalaire transcendantale. Il en en théorie possible de faire, en même temps, un calcul dans chaque ALU. Pendant que l'unité transcendantale fait un calcul trigonométrique quelconque, l'autre ALU effectue des calculs SIMD sur d'autres données.
Pour cela, une possibilité est d'utiliser des instructions à co-issue. Le problème est que ces instructions sont surtout utiles pour exécuter des instructions scalaires en parallèle d'instruction SIMD, mais guère plus. Aussi, nous allons mettre la co-issue de côté. Dans ce qui suit, nous allons partir du principe que le processeur peut démarrer une nouvelle instruction par cycle d'horloge. Et cela permet malgré tout d’exécuter plusieurs instructions en même temps dans des unités de calcul séparées. La raison est que la plupart des instructions prend plusieurs cycles d'horloge à s’exécuter. Ainsi, pendant qu'une instruction en est à son second ou troisième cycle dans une ALU, il est possible de démarrer une nouvelle instruction dans une ALU inoccupée, s'il y en a une. La seule contrainte est que les deux instructions soient indépendantes.
Les processeurs de shaders en sont capables, tout comme les CPU. Mais les CPU utilisent au mieux cette possibilité. Ils intègrent des circuits d'exécution dans le désordre, de renommage de registre, et bien d'autres. Mais leur implémentation demande un budget en transistors conséquent, que les GPU ne peuvent pas se permettre. A la place, ils utilisent une technique appelée le multithreading matériel, une technique qui vient du monde des processeurs, où cette technique a été appliquée à de nombreuses reprises. Vous connaissez sans doute l'hyperthreading d'Intel ? C'est une version basique du multithreading matériel.
L'idée est d'exécuter plusieurs programmes en même temps sur le même processeur, le processeur commutant de l'un à l'autre suivant les besoins. Par exemple, si un thread est bloqué par un accès mémoire, d'autres threads exécutent des calculs dans l'unité de calcul en parallèle de l'accès mémoire. Pour un GPU, les programmes en question sont des instances de shader qui travaillent sur des données différentes. Ces instances de shader portent les noms de warp dans la terminologie NVIDIA. Un processeur de shader commute donc régulièrement d'un warp à l'autre, suivant les besoins. Dans ce qui va suivre, nous allons voir dans quelles situations un processeur de shader change de thread/warp en cours d'exécution. Suivant le GPU, les situations ne sont pas les mêmes.
Les lectures non-bloquantes avec multithreading matériel
[modifier | modifier le wikicode]Les processeurs de shader sont connectés à une mémoire vidéo très lente, avec un temps d'accès élevé, qui se rattrape avec un débit binaire important. La conséquence est qu'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. Et cs derniers ne peuvent pas recourir à des techniques avancées communes sur les CPU, comme l’exécution dans le désordre : le cout en circuit serait trop important. Fort heureusement, les processeurs de shaders disposent d'optimisations pour remplir ces temps d'attente avec des calculs indépendants de la texture lue.
Les anciennes cartes graphiques d'avant les années 2000 géraient une forme particulière de lectures non-bloquantes. L'idée est simple : pendant qu'on effectue une lecture dans l'unité de texture, on peut faire des calculs en parallèle dans les ALU de calcul. Mais la technique n'est possible que si les instructions de calcul n'ont pas comme opérande un texel en cours de lecture. Il ne faut pas qu'il y a ai une dépendance entre les instructions exécutées dans l'ALU et un texel pas encore lu. Pour cela, la carte graphique fait des vérifications sur les registres. La lecture charge le texel dans un registre, appelé le registre de destination (sous entendu, de destination de la lecture). Les instructions qui n'utilisent pas ce registre peuvent s'exécuter sans problèmes : elles sont indépendantes de la lecture. Mais les autres ont une dépendance et ne sont pas exécutées.
Détecter les dépendances demande juste de mémoriser le registre de destination dans un registre temporaire, et de faire des comparaisons. Pour cela, le processeur de shader incorpore un circuit situé juste après le décodeur d'instruction, appelé l'unité d'émission. Elle compare les registres utilisés par l'instruction avec le registre de destination. Si il y a correspondance, l'instruction est mise en attente. La mise en attente bloque alors toutes les instructions suivantes. En clair, dès qu'on tombe sur une instruction dépendante, le processeur cesse d'émettre des instructions dans l'ALU, et attend la fin de la lecture. Il n'a pas vraiment le choix. Faire autrement serait possible, mais demanderait d'implémenter des techniques d'exécution dans le désordre très gourmandes en circuits.
Depuis l'époque de Direct X 9, les GPU combinent les lectures non-bloquantes avec le multithreading matériel. L'idée est que si un thread effectue un accès mémoire, le thread est mis en pause pendant l'accès mémoire et laisse la place à un autre. Le thread est réveillé une fois que l'accès mémoire est terminé. L'avantage est que si un thread est mis en pause par une lecture, les autres threads récupèrent les cycles d'horloge du thread mis en pause. Par exemple, sur un processeur qui gère 16 threads concurrents, si l'un d'eux est mis en pause, le processeur changera de thread tous les 15 cycles au lieu de 16. Ou encore, un thread aura droit à deux cycles consécutifs pour s’exécuter. Faire ainsi marche assez bien si on a beaucoup de threads dont peu d'entre eux font des lectures/écritures.
L'avantage est que si un thread est mis en pause par une lecture, les autres threads récupèrent les cycles d'horloge du thread mis en pause. Par exemple, sur un processeur qui gère 16 threads concurrents, si l'un d'eux est mis en pause, le processeur changera de thread tous les 15 cycles au lieu de 16. Ou encore, un thread aura droit à deux cycles consécutifs pour s’exécuter. Faire ainsi marche assez bien si on a beaucoup de threads dont peu d'entre eux font des lectures/écritures.
L'implémentation de cette technique est assez simple au premier abord. L'unité de chargement a plusieurs program counter, un par thread. Un circuit dédié sait quels threads sont mis en pause, et en envoie un aux unités de calculs. La technique impose cependant que les registres soient dupliqués, pour que chaque thread ait ses propres registres rien qu'à lui. Sans cela, impossible de passer rapidement d'un thread à l'autre à chaque cycle. Un processeur de shader peut exécuter entre 16 et 32 threads/warps, ce qui multiplie le nombre de registres par 16/32. Et il y a la même chose avec d'autres structures matérielles, comme les files de lecture/écriture de l'unité d'accès mémoire.

Le scoreboard des GPU des années 2000-2010
[modifier | modifier le wikicode]Les lectures non-bloquantes permettent de gérer le cas des instructions d'accès mémoire. Mais il y a d'autres instructions qui prennent plus d'un cycle d'horloge pour s'exécuter. Les instructions de calcul basiques d'un GPU prennent 2 à 3 cycles d'horloge dans le meilleur des cas, parfois plus d'une dizaine. De telles instructions sont autant d'opportunités pour exécuter plusieurs instructions en même temps. Pendant qu'une instruction multicycle occupe une ALU, les autres ALU peuvent accepter d'autres instructions.
Les processeurs de shaders SIMD peuvent démarrer une nouvelle instruction scalaire/SIMD par cycle, si les conditions sont réunies. Le problème est alors le suivant : deux instructions démarrées l'une après l'autre doivent occuper des ALU différentes, mais en plus elles doivent manipuler des données différentes.
Prenons un exemple simple : une instruction calcule un résultat, qui est utilisé comme opérande par une seconde instruction. La seconde ne doit pas démarrer tant que la première n'a pas enregistré son résultat dans les registres. Il s'agit d'une dépendance dite RAW (Read After Write) typique, que la carte graphique doit gérer automatiquement. Il existe aussi d'autres dépendances liées au fait que deux instructions utilisent les mêmes registres, mais laissons-les de côté pour le moment. Il faut dire qu'avec 4096 registres par shader, elles sont plus rares. Les dépendances en question sont regroupées sous le terme de dépendances de données.
S'il y a une dépendance de données entre deux instructions, on ne peut pas les exécuter en parallèle dans des unités de calcul séparées. Sur les cartes graphiques des années 2000-2010, un circuit dédié appelé le scoreboard détectait ces dépendances. Pour cela, il vérifie si deux instructions utilisent les mêmes registres.
- Précisons que le grand nombre de registres et de threads fait qu'un scoreboard classique, tel que décrit dans les cours d'architecture des ordinateurs, devient rapidement impraticable. Aussi, une implémentation alternative est utilisée, bien que les détails ne soient pas connus à ce jour. Quelques brevets donnent des détails d'implémentation, mais on ne sait pas s'ils sont à jour. Les curieux peuvent tenter de lire le brevet américain numéro #7,634,621, nommé "Register File Allocation", déposé par NVIDIA durant décembre 2009.
A chaque cycle, le scoreboard reçoit une instruction provenant du décodeur et vérifie si elle peut être exécutée. Il vérifie les dépendances de données, mais aussi d'autres formes de dépendances. Notamment, il vérifie qu'il y a une unité de calcul libre pour exécuter l'instruction.S'il n'y a pas de dépendance, elle est envoyée à une unité de calcul inoccupée, s'il y en a une. Mais s'il y a une dépendance de donnée, le processeur de shader met en pause le thread/warp et bascule sur un autre thread. Le thread est mis en pause tant que la dépendance n'est pas résolue. A chaque cycle, le processeur détecte les dépendances et choisit une instruction parmi celles qui peuvent s'exécuter, peu importe leur thread.
L'implémentation sépare le processeur de shader en deux sections séparées par une mémoire tampon qui met en attente les instructions SIMD. Les instructions SIMD sont chargées et décodées, puis sont placées dans une ou plusieurs files d'instruction. Le cas le plus simple à comprendre utilise une file d'instruction par thread. Les instructions attendent leur tour dans la file d'instruction. L'unité d'émission vérifie à chaque cycle quelles instructions sont prêtes à s'exécuter, dans chaque file d'instruction. L'unité d'émission choisit un thread et envoie l'instruction la plus ancienne dans la file d'instruction aux unités de calcul. Les instructions qui ont besoin d'un résultat pas encore calculé ou lu depuis la mémoire attendent juste.

Mais pour cela, il faut que ces derniers aient des instructions en attente dans la file d'instruction. Le processeur doit avoir chargé assez d'instructions en avance, il faut que chaque thread ait assez de réserve pour exécuter plusieurs instructions consécutives. Pour éviter ce problème, le processeur profite du fait que le chargement des instructions depuis la mémoire et leur exécution se font en même temps : le processeur charge des instructions pendant qu'il en exécute d'autres. Plus haut, nous avions dit que l'unité de chargement mémorise plusieurs program counter, un par thread, et choisit à chaque cycle l'un d'entre eux pour charger une instruction. Le choix en question est synchronisé avec l'émission des instructions. Si un thread émet une instruction, ce même thread charge une instruction au même moment. Sauf si la file d'instruction du thread est déjà pleine, auquel cas un autre thread est choisit.
L'encodage explicite des dépendances sur les GPU post-2010
[modifier | modifier le wikicode]Depuis environ 2010, les GPU n'utilisent plus de scoreboard proprement dit. A la place, les GPU modernes déportent la détection des dépendances de données à la compilation. L'idée est que chaque instruction contient quelques bits pour dire au processeur : tu peux lancer 1, 2, 3 instructions à la suite sans problème. La technique porte le nom d'anticipation de dépendances explicite (Explicit-Dependence lookahead). Un exemple historique assez ancien est le processeur Tera MTA (MultiThreaded Architecture), qui utilisait cette technique.
Les GPU NVIDIA modernes utilisent plusieurs bits par instruction pour gérer les dépendances de données. Un premire mécanisme est utilisé pour bloquer l'émission d'une nouvelle instruction pendant x cycles. Il utilise un stall counter, qui mémorise le nombre de cycles d'attente. Une instruction peut initialiser le stall counter avec une valeur de base, qui indique combien de cycles attendre. Le stall counter est décrémenté à chaque cycle d'horloge et une nouvelle instruction s'exécute seulement quand le compteur atteint 0. Il est utilisé quand une instruction productrice prenant X cycles est suivie par une instruction consommatrice. Le stall counter est alors initialisé à la valeur X.
La méthode précédente ne vaut cependant que pour les instructions dont le compilateur peut prédire la durée. En clair, les accès mémoire et certaines instructions spéciales ne sont pas prises en charge. Pour les gérer, les GPU modernes utilisent un autre mécanisme, basé sur des compteurs de dépendances. Il y en a plusieurs par thread, entre 5 et 10 selon le GPU. Une instruction productrice réserve un de ces compteur, l'incrémente quand elle démarre son exécution/est émise, le décrémente quand la dépendance est résolue. Les instructions consommatrices doivent attendre que le compteur tombe à zéro pour s'exécuter. Elles précisent quels compteurs regarder avec un masque de compteurs de dépendance, encodé directement dans l'instruction elle-même. Le masque est composé de bits à 0/1, un par compteur.
Précisément, chaque instruction productrice se réserve deux compteurs, pour gérer les trois types de dépendances de données (RAW, WAR et WAW). Le premier compteur est décrémenté quand le résultat est écrit dans les registres, ce qui gère naturellement les dépendances RAW et WAW. Le second est décrémenté quand l'instruction a lu ses opérandes, ce qui gère les dépendances WAR. Les autres instructions regarde l'un ou l'autre des compteurs selon leur situation.
Il arrive même que le switch de thread soit géré par des bits intégrés dans l'instruction. Par exemple, sur les GPU NVIDIA modernes, chaque instruction contient un bit yield qui indique qu'il faut changer de thread une fois l'instruction émise. En clair, il indique que l'instruction risque de durer longtemps, a des dépendances avec la mémoire ou autre chose qui fait qu'il est préférable de changer de thread.
Le banc de registres d'un processeur de shader
[modifier | modifier le wikicode]Les GPU disposent d'un grand nombre de registres. Les normes de DirectX et Open GL imposent que les shaders modernes gèrent au moins 4096 registres généraux par instance de shader, avec des registres spécialisés en plus. En soi, 4096 est énorme ! Mais au-delà de ces normes, le FGMT implique de dupliquer des registres par le nombre de threads hardware simultanés. Avec le FGMT, les registres devront être dupliqués pour que chaque thread ait ses propres registres rien qu'à lui. Sans cela, impossible de passer rapidement d'un thread à l'autre à chaque cycle.
Maintenant, faisons quelques calculs d'épiciers. Un processeur de shader peut exécuter entre 16 et 32 threads/warps, ce qui multiplie le nombre de registres par 16/32. En multipliant par les 4096 registres nécessaires, cela fait 128 kilooctets de mémoire rien que pour les registres. Et c'est pour un seul cœur ! Si on multiplie par le nombre de cœurs, on trouve que les cartes graphiques modernes ayant plusieurs processeurs de shaders ont facilement entre 32 768 et 65 536 registres de 32 bits, ce qui est énorme ! Il y a plus de mémoire gaspillée dans les registres que dans le cache L1 ou L2 !
Et ce grand nombre de registres par cœur pose quelques problèmes. Les registres sont regroupés dans une petite mémoire SRAM, adressable, appelée le banc de registre. Et comme toutes les mémoires, plus ce banc de registres est grand, plus il est lent. En conséquence, lire un opérande dans les registres prend beaucoup de temps. Du moins, c'est le cas sans optimisations. Et les GPU implémentent de nombreuses parades pour limiter le nombre de registres réellement présents dans leur silicium.
L'allocation dynamique/statique des registres par thread
[modifier | modifier le wikicode]Les GPU modernes n'implémentent pas le nombre maximal de registres demandés. Par exemple, si je prends les GPU AMD de type RDNA 4, il peuvent gérer 16 threads hardware simultanés, chacun ayant accès à 256 registres, registres faisant 128 octets chacun. On s'attend à avoir un banc de registre de 16 * 256 * 128 octets, soit 512 Kilo-octets. En somme, un banc de registre de la taille du cache L1. Sauf que les GPUs en question intègrent moins de registres que prévu ! Ils ont précisément une taille de 192 kilo-octets, soit 96 registres pour chacun des 16 threads.
En effet, 256 registres est un nombre maximal, que la plupart des shaders n'utilise pas totalement. La plupart des shaders utilise entre 64 et 128 registres, rarement moins, rarement plus. Aussi, le GPU partitionne le banc de registres à la demande entre les threads, en leur donnant seulement une partie des registres. Le partitionnement peut être pseudo-statique, à savoir que le banc de registre est découpé en parts égales pour chaque thread, ou dynamique avec un nombre de registre variant d'un thread à l'autre, selon les besoins.
Prenons l'exemple d'un partitionnement pseudo-statique, avec l'exemple des GPU AMD RDNA 1 et 2. Leur banc de registre fait 1024 registres, de 128 octets chacun, soit 128 KB au total. Le GPU gère 16 threads simultanés maximum. Avec un seul thread d'exécuté, le thread unique peut utiliser les 1024 registres du banc de registre pour lui tout seul. Avec deux threads, chacun aura droit à 512 registres, soit la moitié du cas précédent. Avec 16 thread simultanés, chaque thread a accès à 64 registres, pas plus. Et ainsi de suite : le nombre de registre par thread est égal à la taille du banc de registre divisée par le nombre de threads.
A l'heure où j'écris ces lignes, courant 2025, les GPU Intel se contentent d'un partitionnement statique très limité. Avant les GPU d'architecture Battlemage, il n'y avait pas de partitionnement du banc de registre, tous les threads avaient 128 registre à leur disposition. Les GPU Battlemage et ses successeurs ont introduit un partitionnement limité, avec deux modes : un mode sans partitionnement où tous les threads ont accès à 128 registres, un mode avec partitionnement qui divise le nombre de thread par deux et leur donne chacun 256 registres. Pas de possibilité de diviser plus le nombre de threads.
Les GPU AMD et NVIDIA sont eux plus compétents niveau partitionnement statique. Par exemple, les GPU RDNA 4 supportent les partitionnements suivants :
- 16 threads avec 96 registres chacun ;
- 12 threads avec 120 registres chacun ;
- 10 threads avec 144 registres chacun ;
- 9 threads avec 168 registres chacun ;
- 8 threads avec 192 registres chacun ;
- 7 threads avec 216 registres chacun ;
- 6 threads avec 240 registres chacun ;
- 5 threads avec 256 registres chacun.
Le partitionnement pseudo-statique est simple à implémenter, il ne demande pas beaucoup de circuits pour fonctionner. Il est rapide et a de bonnes performances pour le rendu graphique en rastérisation. La raison est qu'en rastérisation, les différents threads sont souvent des copies/instances d'un même shader qui travaillent sur des données différentes. Leur donner le même nombre de registres colle bien avec cet état de fait.
Cependant, si les différents threads sont des shaders différents, les choses ne sont pas optimales. Un shader utilisera plus de registres que l'autre, leur donner le même nombre de registres n'est pas optimal. Par exemple, imaginons que l'on a deux shaders, nommés shaders 1 et 2, aux besoins différents, l'un étant gourmand en registres et l'autre très économe. Dans ce cas, il faudrait partitionner le banc de registre pour donner plus de registre au premier et moins au second. Il s'agit là d'un partitionnement dynamique.
Le partitionnement dynamique est plus optimal pour gérer des shaders déséquilibrés niveau registres, mais a une implémentation matérielle plus complexe. Il a été introduit assez tard, car il a fallu attendre que le rayctracing se démocratise. En effet, le partitionnement dynamique du banc de registre est surtout utile pour le raytracing. Exécuter simultanément des shaders déséquilibrés en registres est peu fréquent avec la rastérisation, beaucoup plus courant avec le raytracing.
NVIDIA et AMD ont des implémentations différentes du partitionnement dynamique. Les GPU AMD RDNA 4 allouent un nombre minimal de registre à chaque thread, mais ils peuvent demander d'avoir accès à plus de registres si besoin. Quand un thread a besoin de plus de registres, il exécute une instruction dédiée, qui sert à demander plus de registres, ou au contraire à en libérer s'ils sont inutilisés. La demande d'allocation de nouveaux registres se fait par blocs de 16 à 32 registres, suivant comment est configuré le processeur. Précisons que l'instruction d'allocation n'est disponible que pour les compute shaders et pas les shaders graphiques.
L'instruction d'allocation de registre précédent peut échouer dans certains cas. Si assez de registres sont disponibles, à savoir inutilisés par d'autres threads, l'instruction réussit. Dans le cas contraire, elle échoue et le shader est mis en pause avant de retenter cette demande plus tard. Le résultat de l'instruction, échec ou réussite, est mémorisé dans le registre d'état. La technique a pour défaut que certaines situations peuvent mener à un blocage complet du processeur, où chaque thread ne peut plus poursuivre son exécution, faute de registres disponibles. Des méthodes pour éviter cette situation sont implémentés sur ces GPU, mais la documentation n'est pas très explicite.
Sur les GPU NVIDIA, il y a aussi une instruction d'allocation de registre, mais elle fonctionne différemment. Elle permet d'échanger des registres entre threads. Une première différence est que tous les threads commencent avec une allocation égale des registres. Les threads démarrent tous avec le même nombre de registres. Un thread peut libérer des registres, qui sont alors alloués à un autre thread, le thread en question pouvant être choisit par le thread qui libère les registres.
Le banc de registre est multiport de type externe
[modifier | modifier le wikicode]Le banc de registre doit permettre de lire deux vecteurs SIMD par opération, soit deux lectures simultanées. Pour cela, le banc de registre contient deux ports de lecture, chacun permettant de lire un opérande dans le banc de registre. Mais plus le nombre de ports augmente, plus la consommation énergétique du banc de registre augmente, sans compter que celui-ci devient plus lent.
Les processeurs peuvent utilisent des bancs de registres ayant réellement deux ports par banc de registre. Un port de lecture est implémenté avec un composant appelé un multiplexeur, connecté à tous les registres.

Les GPU ne peuvent pas se permettre un tel luxe. Leur banc de registre doit alimenter plusieurs unités de calcul en même temps, en parallèle. Le nombre de ports serait plus proche de 4 à 10 ports de lectures. La solution précédente aurait un budget en transistor et un budget thermique trop important. À la place, ils utilisent une autre méthode : ils simulent un banc de registre à plusieurs ports avec un ou plusieurs bancs de registres à un port. On parle alors de multiport externe.

Il existe plusieurs méthodes de multiport externe. Mais celle utilisée sur les GPU simule un banc de registre multiport à partir de plusieurs bancs de registres à un port. Le banc de registre est en réalité formé de plusieurs banques, de plusieurs bancs de registre séparés, chacun mono-port. L'idée est que si l'on accède à deux banques en même temps, on peut lire deux opérandes, une par port/banc de registre. Par contre, si les deux opérandes à lire sont la même banque, il y a un conflit d'accès aux banques.

L'Operand Collector et les caches de register reuse
[modifier | modifier le wikicode]Sans conflit d'accès à une banque, les deux opérandes sont disponibles immédiatement. Par contre, en cas de conflit d'accès aux banques, les deux opérandes sont lus l'une après l'autre. En clair, le premier opérande doit être mis en attente quelque part pendant que la seconde est en cours de lecture. Et le problème survient souvent, surtout avec les opérations FMA qui utilisent trois opérandes, encore plus avec les rares opérations qui demandent 4 à 5 opérandes.
Pour gérer les conflits d'accès aux banques, les GPU utilise un circuit dédié, appelé le collecteur d'opérandes (operand collector). Le rôle du collecteur d'opérandes est d'accumuler les opérandes en attente, son nom est assez transparent. Il accumule les opérandes en attente, puis les envoie aux unités de calcul quand elles sont toutes prêtes, toutes lues depuis le banc de registres. Les opérandes sont mis en attente dans des entrées, qui contiennent la donnée, l'identifiant du thread/warp pour ne pas confondre des opérandes entre threads, et deux bits d'occupation. Les deux bits d'occupation indiquent si l'entrée est vide, réservée pour un opérande en cours de lecture ou occupée par un opérande.
Le collecteur d'opérande est souvent accompagné de registres temporaires, qui mémorisent le résultat d'une instruction précédente. Il y a un registre temporaire par ALU, le résultat fournit par une ALU est mémorisé dans le registre temporaire associé. Une instruction peut lire une opérande dans un registre temporaire, ce qui permet de lire le résultat d'une instruction précédente sans passer par le banc de registres. Le collecteur d'opérande est alors configuré pour récupérer les opérandes adéquats dans les registres temporaires adéquats.
- Pour faire une comparaison avec les processeurs modernes, ces registres sont une forme de data forwarding, de contournement, mais qui est rendue explicite pour le logiciel.
Une optimisation des GPU récent vise à réduire les accès aux bancs de registres en utilisant une mémoire cache spécialisée. Il s'agit de l'operand reuse cache, aussi appelés register reuse cache. L'idée est que quand un opérande est lu depuis le banc de registres, elle peut être stockée dans ce cache pour des utilisations ultérieures. Les architectures Volta, Pascal et Maxwell disposent de 4 caches de ce type, chacun stockant 8 données/opérandes/résultats
Il s'agit en réalité de pseudo-caches, car ils sont partiellement commandés par le logiciel. Une instruction précise qu'un opérande doit être stocké dans un register reuse cache, pour une utilisation ultérieure. Pour cela, elle incorpore quelques bits pour préciser qu'elle doit être placée dans le cache. Les bits font en quelque sorte partie du mode d'adressage. Si l'instruction immédiatement suivante lit ce registre dans le même slot d'opérande lira l'opérande dans le cache. La technique est donc assez limitée, mais elle a des résultats pas négligeables.
Les register reuse cache et le collecteur opérandes sont sans doute fusionnés en un seul circuit, plutôt que d'utiliser deux circuits séparés. La raison est que les deux doivent mémoriser des opérandes les mettre en attente pour une utilisation ultérieure, et sont placés juste après le banc de registre. NVIDIA a publié deux brevets à propos de ces deux techniques, mais rien n'indique que c'est exactement cette technique qui utilisée dans les cartes modernes. Il faut dire que le nombre de banques a changé suivant les cartes graphiques.
Les processeurs de shaders VLIW
[modifier | modifier le wikicode]Une autre forme d'émission multiple est l'usage d'un jeu d'instruction VLIW et/ou d'instructions en co-issue, abordés dans les chapitres précédents. Pour rappel, un processeur VLIW regroupe plusieurs opérations en une seule instruction machine. L'instruction encode les calculs à faire en parallèle, en co-issue. Elle précise les registres, l'opcode de l'instruction, et tout ce qu'il faut pour faire les calculs, et attribue implicitement une unité de calcul à chaque opération.
Les opérations regroupées sont garanties indépendantes par le compilateur, ce qui fait que le décodeur d'instruction envoie chaque opération à l'unité de calcul associée, sans avoir à faire la moindre vérification de dépendances entre instructions. L'unité d'émission est donc grandement simplifiée, elle n'a pas à découvrir les dépendances entre instructions.
Au passage, cela explique pourquoi les premières cartes graphiques étaient de type VLIW, alors que les modernes sont de type SIMD. Les anciennes cartes graphiques préféraient se passer de scoreboard. Mieux valait utiliser le peu de transistors dont elles disposaient pour des unités de calcul. De plus, DirectX 8 et 9 profitaient pas mal de la présence de co-issue. Par contre, il fallait un compilateur performant pour en profiter, ce qui n'était pas vraiment le cas. Les compilateurs n'exploitaient pas beaucoup la co-issue, ce qui fait que les fabricant de GPU ont préféré déléguer cette tâche à un scoreboard matériel.
Les architectures VLIW pures, sans unité SIMD
[modifier | modifier le wikicode]Un processeur VLIW contient un grand nombre d'unités de calcul. En théorie, il incorpore plusieurs unités de calcul scalaires séparés et n'a pas d'unité de calcul SIMD. Il y a cependant quelques exceptions, mais nous les verrons plus tard. Pour le moment, concentrons-nous sur les processeurs de shaders VLIW sans aucune unité de calcul SIMD.
Un exemple est celui des anciennes cartes graphiques AMD, d'architecture TeraScale/VLIW-5, à savoir les Radeon HD 2000/3000/4000/5000/6000. L'architecture était une architecture VLIW, pas SIMD, d'où la distinction. Il n'y avait pas d'unité de calcul SIMD, mais plusieurs unités de calcul scalaires. Un point important est que les unités de calculs scalaires pouvaient faire des opérations différentes. Par exemple, la première pouvait faire une addition flottante, la seconde une addition entière, la troisième une soustraction, etc.
Elles disposaient de six unités de calcul : cinq unités de calcul scalaires, et une unité de calcul dédiée aux branchements. Sur les 5 unités de calcul, une était une unité de calcul hybride scalaire/transcendentale. Nous allons donc faire la distinction entre unité de calcul spéciale et les 4 unités de calcul basiques.
Toutes les unités de calculs pouvaient faire les opérations suivantes, sur des flottants et entiers sur 32 bits : comparaisons, additions, soustractions, opérations logiques, décalages, opérations bit à bit et instructions CMOV. Les unités de calcul basiques gèrent aussi les multiplications, opérations MAD et produits vectoriels/scalaires, mais seulement pour des opérandes flottantes. L'unité de calcul spéciale gérait des multiplications et division entières sur des opérandes 32 bits, ainsi que des instructions transcendantales entières/flottantes.
Par la suite, avec l'architecture VLIW-4, l'unité de calcul transcendantale a été retirée. Mais les calculs transcendantaux n'ont pas disparus. En effet, il ne resta que 4 ALU flottantes, qui ont été augmentées pour gérer partiellement les opérations transcendantales. Tout se passait comme si l'ALU transcendantale avait été éclatée en morceaux répartis dans chaque ALU flottante/entière. Et c'est globalement ce qui s'est passé : les diverses tables matérielles utilisées pour les calculs transcendantaux ont été dispersés dans les ALU, afin de faire des calculs transcendantaux approchés. En combinant les résultats approchés, on pouvait calculer le résultat exact.
Les hybrides SIMD/VLIW et les instructions à co-issue
[modifier | modifier le wikicode]La gestion des instructions en co-issue peut aussi utiliser les techniques des processeurs VLIW. Pour rappel, l'exemple typique d'instruction en co-issue regroupe une opération SIMD avec une opération scalaire, pour profiter de la présence d'une ALU scalaire séparée de l'ALU SIMD. Les cartes graphiques modernes gérent la co-issue aisni, avec la possibilité de co-issue une opération scalaire et une opération vectorielle. L'opération scalaire est souvent une opération entière, éventuellement flottante. Les processeurs de shaders qui supportent de telles instructions sont un hybride entre VLIW et SIMD.
Sur les anciennes cartes graphiques disposant d'une unité SIMD, la co-issue fonctionnait différemment, car les unités de calcul entières n'étaient pas présentes. De plus, le faible budget en transistor faisait que l'on ajoutait pas d'unité flottante scalaire, ce qui ne servait pas à grand-chose. Par contre, l'unité de calcul transcendantale était systématiquement présente, car très utile. Aussi, une forme plus limitée de co-issue était possible : on pouvait exécuter une opération transcendantale en parallèle d'une instruction SIMD. Le cas le plus simple est le processeur de vertices de la Geforce 3, avec une unité SIMD et une unité transcendantale. Il n'y avait pas d'unité de calcul scalaire entière, ni même flottante.
Un autre 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/textures, avec deux unités de calcul. La première est une unité de calcul scalaire flottante, qui gère les opérations flottantes transcendantales. Elle travaille sur des opérandes de 32 bits. À côté, on trouve une unité de calcul SIMD, qui gère des vecteurs de 4 nombres flottants 32 bits. Elle permet de faire des additions, des multiplications, des opérations MAD, des produits vectoriels, et quelques autres opérations comme le calcul du maximum/minimum de deux nombres. Il n'y a aucune gestion d'instructions flottantes scalaires ou d'opérations entières, la carte graphique est trop ancienne pour cela.

Le processeur de pixel shader de la même carte graphique était lui aussi un hybride entre VLIW et SIMD. Cependant, il lorgnait plus du côté VLIW que SIMD. Il pouvait faire soit une opération SIMD le vecteur, soit couper le vecteur en deux et effectuer deux opérations différentes sur chaque morceau. Par exemple, il pouvait faire une opération sur 3 pixels, et une opération scalaire sur le quatrième, ou deux opérations vectoriels chacune sur deux pixels. Le processeur travaille sur des blocs de 4 pixels, appelés des quads. Chaque pixel est codé avec 4 flottants 32 bits, cela fait en tout 4 vecteurs de 4 flottants, avec co-issue à l'intérieur de chaque vecteur. Précisons que les registres temporaires du processeur mémorisent chacun un vecteur de 4 flottants, un pixel, par un quad.
Niveau unités de calcul, le tout était assez complexe. Il contenait tout d'abord une unité de texture, et plusieurs unités VLIW/SIMD. Elles sont appelées "unités SIMD" dans les schémas qui vont suivre, mais elles sont en réalité un mix entre unité SIMD véritable et unités scalaires du VLIW. La documentation NVIDIA elle-même parle d'unité vectorielle, mais le terme est quelque peu trompeur, car elles sont plus flexibles que de simples unités SIMD.
Il y en a deux, la première envoyant son résultat à la seconde. La première est capable de faire des opérations de multiplications/MAD, mais elle peut aussi être utilisée pour la correction de perspective grâce à son support des opérations 1/x. Elle peut aussi normaliser des nombres flottants sur 16 bits. Il faut noter que la première unité SIMD/VLIW ne peut pas être utilisée si un accès mémoire/texture est en cours. La seconde unité SIMD/VLIW est capable de faire des opérations de MAD, et un produit vectoriel/scalaire DOT4.
Le résultat de la seconde ALU est ensuite envoyé à une unité de branchement qui décide s'il faut ré-exécuter une autre passe d'instructions ou non. Il s'agit vraisemblablement d'une unité qui gère la prédication des résultats. Une fois le fragment/pixel final calculé, il est envoyé à une unité de calcul du brouillard, qui est une unité de calcul spécialisée travaillant sur des nombres entiers (en réalité, en virgule fixe, mais c'est pareil).
