Les cartes graphiques/La microarchitecture des processeurs de shaders
La conception interne (aussi appelée microarchitecture) des processeurs de shaders possède quelques idiosyncrasies. Mais avant d'expliquer lesquelles, nous devons prévenir d'une chose importante : dans ce chapitre, nous ne parlerons que des GPU de l'époque DirectX 10 et après, pas des GPU de l'époque DirectX 9. La raison est que leur jeu d'instruction a franchement évolué, avec le passage d'architectures VLIW à des architectures SIMD. Et cela a eu des conséquences assez profondes sur le jeu d'instruction et leur microarchitecture. Nous n'allons parler des GPU de type SIMD dans ce chapitre. Un chapitre dédié sera consacré aux GPU de type VLIW.
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, en dehors d'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).
En clair, un processeur de shader doit savoir faire des calculs. Et il a des circuits dédiés pour ça, appelés des unités de calcul. Les unités de calcul sont souvent classées en deux types : les ALU et les FPU. Les premières font des calculs sur des opérandes entiers, alors que les secondes font des calculs flottants. Au passage, le terme ALU signifie Arithmetic and Logic Unit, alors que FPU signifie Floating Point Unit, les deux termes étant assez parlants. Mais un GPU utilise non seulement des ALU et des FPU, mais aussi des regroupements de plusieurs ALU/FPU. Voyons cela en détail.
Les unités de calcul d'un processeur de shader SIMD
[modifier | modifier le wikicode]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. A cela, il faut souvent rajouter une ALU ou une FPU, parfois des unités de calcul flottantes spécialisées pour les opérations complexes, comme des opérations trigonométriques ou 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'intérieur d'un processeur de shader
[modifier | modifier le wikicode]- Cette section sera surtout des rappels, pour ceux qui ont déjà lu un cours d'architecture des ordinateurs, et connait la notion de pipeline.
En plus des unités de calcul, un processeur contient d'autres circuits, et un processeur de shader ne fait pas exception. Un processeur est composé de quatre circuits principaux :
- les unités de calcul, qui font des calculs et d'autres opérations ;
- les registres pour mémoriser les opérandes des calculs et leurs résultats ;
- une unité mémoire pour échanger des données entre VRAM et registres ;
- une unité de contrôle qui exécute les instructions.
Les unités de calcul, les registres et l'unité mémoire sont souvent regroupés sous le terme de chemin de données. Le terme dit clairement que c'est la partie du processeur qui gère les données, les manipule, fait des calculs, etc. A côté du chemin de données, il y a une unité de contrôle, qui commande ce chemin de données pour qu'il fasse les instructions demandées. L'unité de contrôle lit les instructions depuis la mémoire vidéo, et configure le chemin de données, pour qu'il exécute l'instruction demandée. L'unité de contrôle sera détaillée dans la section suivante.
Le chemin de données d'un processeur de shader
[modifier | modifier le wikicode]Un processeur de shader contient au minimum une unité SIMD, et une unité scalaire. Il est possible d'utiliser les deux en même temps, grâce aux instructions à co-issue. Elles sont surtout utiles pour exécuter des instructions scalaires en parallèle d'instruction SIMD, mais guère plus. Notez que j'ai dit : "au minimum une unité SIMD et une unité scalaire", car les processeurs de shaders modernes dupliquent les unités de calcul, pour des raisons qu'on expliquera dans la suite du chapitre. Il n'est pas rare qu'un processeur de shader dispose d'une dizaine d'unités SIMD et de 2 ou 3 unités scalaires.
Nous avons vu les registres dans le chapitre précédent, aussi je ne vais pas revenir dessus. Je vais juste préciser que les registres sont regroupés dans des bancs de registres. Ce sont de petites mémoires dont chaque adresse contient un registre. En général, il y a un banc de registre pour les scalaires et un autre pour les vecteurs SIMD. Les deux sont séparés car ils ne sont pas utilisés par les mêmes instructions. Et c'est plus pratique à implémenter.
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.
L'unité de contrôle d'un processeur de shader
[modifier | modifier le wikicode]L'unité de contrôle d'un GPU a quelques petites différences avec celles d'un CPU moderne. Les unités de contrôle des GPU n'utilisent pas les optimisations 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. A la place, elles utilisent des techniques alternatives qu'on décrira pdans la suite du chapitre, qui sont peu gourmandes en transistors. En conséquence, les unités de contrôle sont très simples, prennent peu de place, utilisent peu de transistors. La majeure partie du processeur est dédié aux unités de calcul et aux registres.

Un processeur de shader a une unité de contrôle assez classique, composée de plusieurs circuits.
- une unité de Fetch qui calcule l'adresse de la prochaine instruction ;
- un cache d'instruction dans lequel on récupère la prochaine instruction, en présentant son adresse ;
- une unité de décodage d'instruction, qui traduit l'instruction en signaux de commande à destination de l'unité de calcul et des registres ;
- une unité d’issue, aussi appelée le scoreboard, qu'on détaillera dans ce qui suit.
Le tout est illustré ci-dessous, avec le chemin de données. Vous remarquerez que dans le chemin de données, il y a aussi une unité pour enregistres les résultats dans les registres, qui effectue pas mal de traitements importants qu'on ne peut pas détailler ici. Vous remarquerez aussi que l'unité qui calcule l'adresse de la prochaine instruction est un peu complexe. Mais laissons cela de côté pour le moment.

Le pipeline d'un processeur de shader
[modifier | modifier le wikicode]Un point important est que les processeurs de shaders utilisent la technique du pipeline. Les unités vues précédemment fonctionnent indépendamment des autres. Elles n'attendent pas que l'instruction soit terminée pour commencer à traiter la suivante. Concrètement, la première unité traite l'instruction courante, pendant que la suivante traite l'instruction précédente, et ainsi de suite :
- la première unité calcule l'adresse de l'instruction numéro N;
- le cache d'instruction lit l'instruction numéro N-1 ;
- l'unité de décodage décode l'instruction numéro N-2 ;
- le scoreboard analyse l'instruction numéro N-3 ;
- les unités de calcul exécutent l'instruction numéro N-4 ;
- l'unité d'enregistrement écrit le résultat de l'instruction numéro N-5 dans les registres.
Faire ainsi maximise les performances, car cela permet d'exécuter plusieurs instructions en même temps, à des étapes différentes. Le débit d'instruction est maximisé, les shaders s'exécutent plus vite. Le nombre d'étapes exact dépend du processeur. Il arrive que des processeurs fusionnent des étapes en un seul circuit. Par exemple, l'unité de décodage et le scoreboard peuvent être regroupés dans une seule étape, de même que l'accès aux registres et le calcul dans l'ALU. D'autres processeurs scindent certaines étapes en plusieurs sous-circuits séparés. Par exemple, il peuvent scinder leurs ALU en plusieurs sous-ALU, chacune exécutant un morceau de calcul.
Et cela permet d'expliquer pourquoi un processeur de shaders SIMD contient beaucoup d'unités de calcul, identiques, ou non. Les instructions prennent plusieurs cycles d'horloge à s’exécuter, ce qui fait qu'une instruction occupe une unité de calcul pendant 2 à 20 cycles, rarement plus. Cependant, le processeur peut démarrer une nouvelle instruction par cycle d'horloge. Et cela permet malgré tout de démarrer une nouvelle instruction dans une unité de calcul libre. 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, sans voir recours à la co-issue. s'il y en a une.
Par exemple, reprenons l'exemple de l'unité de vertex shader 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 l'ALU SIMD, une lecture de texture dans l'unité mémoire, un calcul trigonométrique dans l'unité transcendantale. Il suffit de lancer la lecture de texture à un cycle, l’instruction MAD au cycle suivant, et l'instruction spéciale deux cycles après.
Le scoreboard d'un processeur de shader
[modifier | modifier le wikicode]Exécuter plusieurs instructions en même temps pose un problème quand deux instructions consécutives sont dépendantes l'une de l'autre. Un cas classique est quand une instruction a besoin du résultat de la précédente. Dans ce cas, l'instruction accède aux registres alors que la première n'a pas encore écrit dedans, ce qui pose un problème. Il s'agit d'une dépendance dite RAW (Read After Write) typique, que la carte graphique doit gérer automatiquement. La seconde instruction ne doit pas démarrer tant que la précédente n'a pas enregistré son résultat dans les registres.
Le scoreboard gère ce genre de problèmes. Il détecte les dépendances entre instructions et les gère sans intervention extérieure. Pour cela, il vérifie si les opérandes de l'instruction sont en cours de calcul. Pour cela, il regarde quels registres l'instruction lit/écrit, et vérifie s'ils sont en cours d'utilisation. Pour savoir quels registres sont en cours d'utilisation, rien de plus simple : quand il démarre une instruction, le scoreboard marque ses registres comme en cours d'utilisation.
Le scoreboard se contente de bloquer l'instruction tant qu'elle ne peut pas s'exécuter. Et les instructions suivantes sont aussi bloquées dans l'étage où elles sont. Rien ne progresse dans le pipeline tant que l'instruction fautive est bloquée. Cependant, les processeurs de shaders disposent de plusieurs optimisations concernant ce genre de situations. Voyons cela en détail.
Et au-delà de ça, le scoreboard bloque l'exécution d'une instruction si les conditions ne sont pas remplies. Notamment, il vérifie qu'il y a une unité de calcul libre pour exécuter l'instruction. Si ce n'est pas le cas, l'instruction est bloquée. Au-delà de ça, il existe d'autres dépendances liées au fait que deux instructions utilisent les mêmes registres. Mais avec 4096 registres par shader, elles sont plus rares, ce qui fait qu'on les laisse volontairement de côté.
Exemple et résumé final
[modifier | modifier le wikicode]Pour finir, nous allons voir un exemple final, celui des GPU Radeon X1000 series, de microarchitecture R500, Terascale. Leur microarchitecture est résumée dans le schéma ci-dessous. La portion gauche du schéma montre plusieurs choses. Le GPU contient un processeur de commande, appelé l'ultra threaded dispatcher sur ces GPU. Il alimente plusieurs processeurs de shaders, ici appelés des compute unit (CU).
De nombreux circuits sont partagés entre plusieurs processeurs. Par exemple, ils partagent un cache L2, dans lequel ils viennent récupérer les données nécessaires. Le GPU contient aussi des contrôleurs mémoire, qui lisent ou écrivent des données en mémoire vidéo. Les contrôleurs mémoire servent surtout d'interface entre la mémoire vidéo et le cache L2, mais ils peuvent aussi envoyer des données lue aux processeurs de shaders.
Le focus de droite montre ce qu'il y a dans un processeur de shader. Déjà, l'unité de contrôle est en haut et est nommée Fetch, Decode, Schedule : Schedule est un synonyme de Issue. L'unité mémoire est appelée la Load Store Unit (LSU), elle communique avec la mémoire vidéo et les registres. A côté, on trouve une unité de calcul SIMD et une unité de calculs scalaire. Pour ce qui est des mémoires, elle montre qu'il y a une petite mémoire locale généraliste, complétée avec deux bancs de registres : un pour les données scalaires, un pour les vecteurs SIMD.
- Les registres pour les scalaires sont appelés les Scalar General Purpose Registers (GPR).
- Les registres pour les vecteurs SIMD sont appelés les Vector General Purpose Registers (VGPR).
- La mémoire locale généraliste, appelée la mémoire partagée (LDS).
Niveau interconnexions, les flèches montrent plusieurs choses. Premièrement, l'unité mémoire est reliée aux bancs de registres, ainsi qu'à la mémoire locale. Les accès à la mémoire locale passent par l'unité mémoire, qui sert d'intermédiaire obligatoire. L'unité SIMD est connectée aux registres SIMD, l'unité scalaire est reliée au banc de registres scalaire. Rien d'étonnant. L'unité SIMD peut aussi lire des scalaires pour certaines instructions SIMD verticales, ce qui fait qu'elle est aussi connecté au banc de registres scalaires.

Le multithreading matériel des processeurs de shaders
[modifier | modifier le wikicode]L'unité d'issue détecte les dépendances de données, et bloque les instructions si elles ne doivent pas s'exécuter. L'inconvénient est que, quand une instruction est bloquée, les instructions suivantes sont aussi bloquées dans l'étage où elles sont. Rien ne progresse dans le pipeline tant que l'instruction fautive est bloquée. Heureusement, les GPU et les CPU disposent de techniques pour surmonter ce blocage, afin de continuer à exécuter des instructions.
Les CPU disposent de techniques d'exécution dans le désordre, de renommage de registre, et bien d'autres. Mais leur implémentation demande un budget en transistor conséquent, que les GPU ne peuvent pas se permettre. A la place, ils utilisent une technique appelée le multithreading matériel, qui vient du monde des CPU. 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 programme est bloqué par un accès mémoire, d'autres programmes 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, mais on peut aussi parler de threads pour utiliser la même terminologie que pour les CPUs. 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.
Il existe trois techniques de multithreading matériel : le Fine Grained Multithreading, le Coarse Grained Multithreading et le Simultaneous MultiThreading. Dans ce qui suit, nous utiliserons l'abréviation FGMT pour parler du Fine Grained Multithreading, de CGMT pour parler du Coarse Grained Multithreading et de SMT pour le Simultaneous MultiThreading. Les GPU ont d'abord implémenté le CGMT dans la période DirectX 9, puis sont passé au FGMT, avant de passer au SMT sur les générations récentes. Aussi, nous allons les voir dans l'ordre.
Le Coarse Grained Multithreading de l'époque DirectX 9
[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 ces 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 utilisent le multithreading matériel pour masquer la latence des accès mémoire. L'idée est que si un thread démarre un accès mémoire, il est mis en pause pendant l'accès mémoire, et laisse sa place à un autre thread. Ainsi, pendant qu'un thread est bloqué par un accès mémoire, un autre thread utilise les unités de calcul en parallèle. Cela permet de masquer la latence des accès mémoire.
- Notons qu'avec cette technique, les lectures mettent en pause le thread qui les exécute. On parle alors de lectures bloquantes. Nous verrons que les processeurs de shader plus récents exécutent des lectures non-bloquantes, mais ce sera pour la suite.
La technique ne donne de bons résultats que si les accès mémoire sont peu fréquents, ou que le nombre de threads est élevé. Plus les accès mémoire sont fréquents, plus il faut un nombre de threads important pour masquer la latence. A l'époque, il était rare que les vertex shader accèdent à des textures, alors que les pixels shaders ne faisaient que ça. Ls processeurs de shaders de la Geforce 6 géraient au maximum 4 threads simultanés, alors que les processeurs de pixel shaders en géraient facilement plus d'une centaine. Les cartes concurrentes d'ATI supportaient 128 threads maximum, par processeur de pixel shader.
L'implémentation de cette technique est assez simple, au premier abord. Déjà, il y a un Program Counter par thread. À chaque cycle, un multiplexeur choisit le Program Counter - le thread - qui a la chance de charger ses instructions. Le choix du program counter sélectionné est le fait de l'unité d'ordonnancement. L'unité d'ordonnancement sait quels threads sont en cours d'exécution et lesquels sont en pause. Pour cela, elle intègre une petite mémoire qui mémorise l'état de chaque thread.

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.

- La technique générale porte le nom de Coarse Grained Multithreading. C'est une forme de Multithreading où on change de programme quand un évènement bien précis a lieu. Les GPU en utilisaient une version où les évènements en question sont des accès mémoire. En théorie, la technique s'adapte pour d'autres opérations que les accès mémoire, tant que celles-ci prennent beaucoup de temps. Mais je ne saurais dire si les GPU l'appliquaient pour autre chose que les accès mémoire.
Interlude propédeutique : le Fine Grained Multithreading
[modifier | modifier le wikicode]
Peu avant les années 2010, les processeurs de shaders ont subit quelques changements, afin d'augmenter leur performance. Notamment, le nombre d'étapes du pipeline a augmenté, histoire d’exécuter plus d'instructions simultanément. Et cela a commencé à poser quelques problèmes : les situations où deux instructions utilisent les mêmes registres a augmenté, les dépendances de données sont devenues un problème.
Et pour résoudre ces problèmes, les GPU ont basculé du CGMT vers une forme de multithreading qu'on ne trouve que sur les GPU, ou presque. Et pour la comprendre, nous allons devoir faire un léger détour par une forme de multithreading très similaire, utilisée sur les CPU. Il s'agit du Fine Grained Multithreading (FGMT).
Avec le FGMT, le processeur de shader change de thread à chaque cycle d'horloge. Le processeur fait donc une rotation, à chaque cycle, parmi les threads actifs. Les threads bloqués par un accès mémoire ne sont pas pris en compte. Par exemple, imaginons que le processeur gère 32 threads simultanés, mais que 8 d'entre eux soient en pause lors d'un accès mémoire. Dans ce cas, il changera de thread tous les 24 cycles, car il ne prend en compte que les threads non-bloqués.
Faire ainsi a de nombreux avantages, notamment pour ce qui est des dépendances entre instructions. En changeant de thread à chaque cycle, on "espace" les instructions d'un même thread. Par exemple, si on a 8 threads qui s'exécutent en même temps, alors il y a 8 cycles d'horloges entre deux instructions d'un même thread. La première instruction a alors eu tout le temps pour enregistrer son résultat dans les registres, avant même que la seconde instruction lise ses opérandes. Le processeur peut alors utiliser un scoreboard très limité, très simple, pour détecter les rares dépendances de données qui restent, voire peut se passer complétement de scoreboard !
Le Multithreading commandé par le scoreboard des années 2005-2010
[modifier | modifier le wikicode]
Sur les GPU récents, le processeur de shader ne change pas de thread à chaque cycle. Il préfère exécuter plusieurs instructions consécutives d'un même thread, dans des cycles d'horloge consécutifs. Il ne change de thread que quand une dépendance de donnée bloque une instruction. C'est donc le scoreboard qui commande le changement de thread, là où le changement de thread était réalisé sans lui avec le CGMT ou le FGMT.
L'implémentation matérielle sépare le processeur de shader en deux sections séparées par une mémoire tampon qui met en attente les instructions. Les instructions 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. A chaque cycle, l'unité d'émission vérifie plusieurs instructions, une par file d'instruction. Il choisit alors une instruction prête, puis l'envoie aux unités de calcul. L'instruction peut venir de n'importe quelle file d'instruction. Mais pour que cela fonctionne, il faut que les files d'instructions soient remplies, ce qui implique que le processeur doit avoir chargée des instructions en avance.
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 choisi.
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.

L'usage d'un scoreboard permet de nombreuses optimisations, qui portent notamment sur les lectures, qui interagissent avec le FGMT. L'optimisation en question s'appelle les lectures non-bloquantes. Elle fonctionne si la lecture est suivie par d'autres instructions qui n’accèdent pas à la mémoire, par exemple des instructions de calculs. L'idée est d'exécuter ces instructions en avance, pendant que la lecture récupère la donnée. C'est possible car la lecture utilise l'unité mémoire et le cache, mais laisse libre les unités de calcul et les registres. En clair, pendant qu'on lit une texture, on fait des calculs en parallèle dans les ALU.
Mais la technique n'est possible que si les instructions de calcul n'utilisent pas la donnée en cours de lecture. Heureusement, le scoreboard détecte si cela arrive en surveillant les registres. La lecture charge une donnée 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 dès qu'une instruction souhaite lire ou écrire dans ce registre, elle est bloquée par le scoreboard et le processeur change de thread. Détecter les dépendances demande juste de mémoriser le registre de destination dans un registre temporaire, et de regarder si l'instruction à exécuter utilise ce registre.
Une différence avec le CGMT est le moment où un thread est bloqué par une lecture. Avec des lectures bloquantes, un thread est bloqué dès que la lecture est lancée. Et ce même si les instructions suivantes sont indépendantes de la lecture. Par contre, avec les lectures non-bloquantes, un thread n'est pas bloqué à ce moment-là. Il continue son exécution jusqu'à ce qu'une instruction accède à la donnée lue. Si la lecture s'est déjà terminée, alors la donnée est disponible et le thread continue de s'exécuter. Mais si la lecture est encore en cours, alors le processeur bloque l'instruction et change de thread.
Une autre optimisation possible est l'usage de l'émission multiple. Avec elle, le scoreboard peut émettre deux instructions lors du même cycle d'horloge, si elles utilisent des unités de calcul différentes. Les deux instructions doivent faire partie du même thread, mais certains GPU acceptent qu'elles soient de deux threads différents, tout dépend du GPU. Par exemple, il est possible qu'une instruction d'un thread utilise l'unité de calcul SIMD, alors que l'autre lance une lecture de texture. Ou encore, les deux peuvent lancer des calculs SIMD, mais dans deux unités SIMD séparées.
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 premier 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 fonctionne que pour les instructions dont le compilateur peut prédire la durée. Les accès mémoire et certains calculs complexes ne sont pas dans ce cas. Pour les gérer, les GPU modernes utilisent un autre mécanisme, basé sur des compteurs de dépendances. Il y en a entre 5 et 10 selon le GPU. Une instruction dite productrice réserve un de ces compteurs, 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, qui utilisent le résultat de l'instruction productrice, attendent 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.
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 regardent l'un ou l'autre des compteurs selon leur situation.
Il arrive que le switch de thread soit déclenché 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 à 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.