Les cartes graphiques/La répartition du travail sur les unités de shaders

La répartition du travail sur plusieurs processeurs de shaders est un vrai défi sur les cartes graphiques actuelles. De plus, elle dépend de l'occupation des circuits fixes, comme l’input assembler ou l'unité de rastérisation. Une carte graphique ne traite pas les sommets ou pixels un par un, mais essaye d'en traiter un grand nombre en même temps, en parallèle. Les cartes graphiques sont conçues pour cela, pour exécuter les mêmes calculs en parallèle sur un grande nombre de données indépendantes. Si on prend l'exemple d'un vertex shader, une carte graphique exécute le même shader sur un grande nombre de sommets en même temps. Pour cela, elle contient plusieurs processeurs de shaders, chacun traitant au moins un sommet à la fois. Il en est de même pour les traitements sur les pixels. Dans ce chapitre, nous allons voir comment le travail est répartit sur les processeurs de shaders.

Les cartes graphiques implémentent un parallélisme de type Fork/join

modifier

Si une carte graphique exécute de nombreux calculs en parallèle, tout ne peut pas être parallélisé et ce pour deux raisons. La première est tout simplement le pipeline graphique, qui impose que le rendu se fasse en étapes bien précises. La seconde raison est que certaines étapes sont relativement bloquantes et imposent une réduction du parallélisme. Tel est le cas de l'étape de rastérisation ou de l'enregistrement des pixels en mémoire, qui se parallélisent assez mal. Elles servent de point de convergence, de point de blocage. Les deux points interagissent et font que le parallélisme sur les cartes graphiques est un peu particulier.

Concrètement, prenons l'exemple d'une carte graphique simple, illustrée ci-dessous. Le pipeline commence avec une unité d'input assembly qui lit les sommets en mémoire vidéo et les distribue sur plusieurs circuits géométriques (des circuits fixes sur les anciennes cartes 3D, des processeurs de shaders sur les modernes). En sortie des unités géométriques, les sommets sont tous envoyé au rastériseur. En sortie du rastériseur, les pixels sont répartis sur plusieurs circuits de traitement des pixels, généralement des processeurs de shaders. Là encore, comme pour les unité géométriques, on trouve un grand nombre de circuits qui effectuent leur travail en parallèle. puis vient le moment d'enregistrer les pixels finaux en mémoire. Il sont alors envoyés à un circuit spécialisé, nommé le ROP.

 
Parallélisme dans une carte 3D

On voit que le rastériseur et les ROP finaux sont des points de convergence où convergent plusieurs sommets/pixels. Pour gérer cette convergence, les cartes graphiques disposent de mémoires tampon, dans lesquelles on accumule les sommets ou pixels. Ce mécanisme de mise en attente garantit que les sommets sont mémorisés en attendant que le rastériseur soit libre, ou que les ROP soient libres. La présence des mémoires tampon désynchronise les différentes étapes du pipeline, tout en gardant une exécution des étapes dans l'ordre. Une étape écrit ses résultats dans la mémoire tampon, l'autre étape lit ce tampon quand elle démarre de nouveaux calculs. Comme cela, on n'a pas à synchroniser les deux étapes : la première étape n'a pas à attendre que la seconde soit disponible pour lui envoyer des données. Évidemment, tout cela marche bine tant que les mémoires tampon peuvent accumuler de nouvelles données. Mais si ce n'est pas le cas, si la mémoire tampon est pleine, elle ne peut plus accepter de nouveaux sommet/pixels. Donc, l'étape précédente est bloquée et ne peut plus démarrer de nouveaux calculs en attendant.

La répartition entre pixel shaders et vertex shaders

modifier

Un triangle est impliqué dans plusieurs pixels lors de l'étape de rastérisation. Un triangle peut donner quelques pixels lors de l'étape de rastérisation, alors qu'un autre va couvrir 10 fois de pixels, un autre seulement trois fois plus, un autre seulement un pixel, etc. Et vu que chaque triangle est impliqué dans plusieurs pixels, cela fait qu'il y a plus de travail à faire sur les pixels que sur les sommets. C'est un phénoméne d'amplification, qui fait que les processeurs de shader sont plus occupés à exécuter des pixel shaders que des vertex shader.

Les architectures avec unités de vertex et pixels séparées

modifier

Les premières cartes graphiques avaient des processeurs séparés pour les vertex shaders et les pixel shaders. Cette séparation entre unités de texture et de sommets était motivée par le fait qu'à l'époque, le jeu d'instruction pour les vertex shaders était différent de celui pour les pixel shaders. Notamment, les unités de traitement de pixels doivent accéder aux textures, mais pas les unités de traitement de la géométrie. D'où l'existence d'unités dédiées pour les vertex shaders et les pixel shaders, chacune avec ses propres capacités et fonctionnalités. Ces architectures ont beaucoup plus de processeurs de pixel shaders que de processeurs de vertex shaders, vu que les pixels shaders dominent le temps de calcul par rapport aux vertex shaders.

 
Carte 3D avec pixels et vertex shaders non-unifiés.

Pour donner un exemple, c'était le cas de la Geforce 6800. Elle comprenait 16 processeurs pour les pixel shaders, alors qu'elle n'avait que 6 processeurs pour les vertex shaders. D'autres cartes graphiques plus récentes utilisent encore cette stratégie, comme l'ARM Mali 400.

 
Architecture de la GeForce 6800.

Malheureusement, il arrivait que certains processeurs de shaders soient inutilisés. Malheureusement, tous les jeux vidéos n'ont pas les mêmes besoins : certains sont plus lourds au niveau géométrie que d'autres, certains ont des pixels shaders très gourmands avec des vertex shaders très light, d'autres font l'inverse, etc. Le fait d'avoir un nombre fixe de processeurs de vertex shaders fait qu'il va en manquer pour certains jeux vidéo, alors qu'il y en aura de trop pour d'autres. Pareil pour les processeurs de pixel shaders. Au final, le problème est que la répartition entre puissance de calcul pour les sommets et puissance de calcul pour les pixels est fixe, alors qu'elle n'est variable d'un jeu vidéo à l'autre, voire d'un niveau de JV à l'autre.

Les architectures avec unités de shaders unifiées

modifier

Depuis DirectX 10, le jeu d'instruction des vertex shaders et des pixels shaders a été unifié : plus de différences entre les deux. En conséquence, il n'y a plus de distinction entre processeurs de vertex shaders et de pixels shaders, chaque processeur pouvant traiter indifféremment l'un ou l'autre.

 
Carte 3D avec pixels et vertex shaders unfifiés.
Précisons qu'il existe des architectures DirectX 10 avec des unités séparées pour les vertex shaders et pixels shaders. L'unification logicielle des shaders n’implique pas unification matérielle des processeurs de shaders, même si elle l'aide fortement.

Cela a un avantage considérable, car tous les jeux vidéo n'ont pas les mêmes besoins. Certains ont une géométrie très développée mais peu de besoin en termes de textures, ce qui fait qu'ils gagnent à avoir beaucoup d'unités de gestion de la géométrie et peu d'unités de traitement des pixels. À l'inverse, d'autres jeux vidéo ont besoin de beaucoup de puissance de calcul pour les pixels, mais arrivent à économiser énormément sur la géométrie. L'usage de shaders unifiés permet d'adapter la répartition entre vertex shaders et pixels shaders suivant les besoins de l'application, là où la séparation entre unités de vertex et de pixel ne le permettait pas.

La distribution du travail sur des processeurs de shaders

modifier

L’input assembler lit un flux de sommets depuis la mémoire et l'envoie aux processeurs de vertex shader. La rastérisation, quant à elle, produit un flux de pixels qui sont envoyés aux pixels shaders et/ou aux unités de textures. Le travail sortant du rastériseur ou de l’input assembler doit donc être envoyé à un processeur de shader libre, qui n'a rien à faire. Et on ne peut pas prédire lesquels sont libres, pas plus qu'il n'est garanti qu'il y en ait un.

Une des raisons à cela est que tous les shaders ne mettent pas le même temps à s'exécuter, certains prenant quelques dizaines de cycles d'horloge, d'autres une centaine, d'autres plusieurs milliers, etc. Il se peut qu'un processeur de shader soit occupé avec un paquet de sommets/pixels depuis plusieurs centaines de cycles, alors que d'autres sont libres depuis un moment. En clair, on peut pas prédire quels processeurs de shaders seront libres prochainement. Planifier à l'avance la répartition du travail sur les processeurs de shaders n'est donc pas vraiment possible. Cela perturbe la répartition du travail sur les processeurs de shader. Pour résoudre ce problème, il existe plusieurs méthodes, classées en deux types : statiques et dynamiques.

La distribution statique

modifier

Avec la distribution statique, le choix n'est pas basé sur le fait que telle unité de shader est libre ou non, mais chaque sommet/pixel est envoyé vers une unité de shader définie à l'avance. L'ARM Mali 400 utilise une méthode de distribution statique très commune. Elle utilise des processeurs de vertex et de pixels séparés, avec un processeur de vertex et 4 processeurs de pixel. L'écran est découpé en quatre tiles, quatre quadrants, et chaque quadrant est attribué à un processeur de pixel shader. Quand un triangle est rastérisé, les pixels obtenus lors de la rastérisation sont alors envoyés aux processeurs de pixel en fonction de leur posituion à l'écran. Cette méthode a des avantages. Elle est très simple et n'a pas besoin de circuits complexes pour faire la distribution, l L'ordre de l'API est facile à conserver, la gestion des ressources est simple, la localité des accès mémoire est bonne, etc. Mais la distribution étant statique, il est possible que des unités de shader soient inutilisées. Il n'est pas rare que l'on a des périodes assez courtes où tout le travail sortant du rastériseur finisse sur un seul processeur.

La distribution dynamique

modifier

La distribution dynamique prend en compte le fait que certaines unités de shader sont inutilisées et tente d'utiliser celles-ci en priorité. Le travail sortant du rastériseur ou de l'input assembler est envoyé aux unités de shader inutilisées, ou du moins à celles qui sont les moins utilisées. Cela garantit d'utiliser au mieux les processeurs de shaders. On n'a plus de situations où un processeur est inutilisé, alors que du travail est disponible. Tous les processeurs sont utilisés tant qu'il y a assez de travail à faire. Par contre, cela demande de rajouter une unité de distribution, qui se charge de répartir les pixels/fragments sur les différents processeurs de shaders. Elle connait la disponibilité de chaque processeur de shader et d'autres informations nécessaires pour faire une distribution efficace.

 
Dispatch des shaders sur plusieurs processeurs de shaders

Par contre, cela implique que les calculs se finissent dans le désordre. Par exemple, si deux pixels sortent du rastériseur l'un après l'autre, ils peuvent sortir de l'unité de pixel shader dans l'ordre inverse. Il suffit que le premier pixel a pris plus de temps à être calculé que l'autre pour ça. Or, l'API impose que les triangles/pixels soient rendus dans un ordre bien précis, de manière séquentielle. Si les calculs sur les sommets/pixels se font dans le désordre, cet ordre n'est pas respecté. Pour résoudre ce problème, et rester compatible avec les API, les processeurs de shader sont suivis par un circuit de remise en ordre, qui remet les pixels sortant des processeurs de pixel dans l'ordre de sortie du rastériseur. Le circuit en question est une mémoire qui ressemble un petit peu à une mémoire FIFO, appelée tampon pseudo-FIFO. Les pixels sont ajoutés au bon endroit dans la mémoire FIFO, mais ils quittent celle-ci dans l'ordre de rendu de l'API.

 
Exécution des shaders dans le désordre et circuits de remise en ordre

Les architectures actuelles font les deux

modifier

La distribution dynamique marche très bien quand il y a un seul rastériseur. Mais les cartes graphiques modernes disposent de plusieurs rastériseurs, pour ces questions de performance. Et cela demande de faire deux formes de répartition : répartir les triangles entre rastériseurs et les laisser faire leur travail en parallèle, puis répartir les fragment/pixels sortant des rastériseurs entre processeurs de shader.

La répartition des fragment/pixels se base sur une distribution dynamique, alors que la répartition des triangles entre rastériseurs se base sur la distribution statique.La distribution statique se base sur l'algorithme avec des quadrants/tiles est utilisé pour la gestion des multiples rastériseurs. L'écran est découpé en tiles et chacune est attribuée à un rastériseur attitré. Il y a un rastériseur par tile dans la carte graphique. le découpage en tiles est aussi utilisée pour l'élimination des pixels non-visibles et pour quelques optimisations du rendu.

La répartition du travail pour les autres tâches que le rendu 3D

modifier

Plus haut, nous avions dit que le rasteriseur et linput assembler s'occupent de la répartition du travail sur les différents processeurs. Ils agissent sous la supervision du processeur de commande qui se charge de répartir le travail sur les différents circuits de la carte graphique. Il commande le rasteriseur, linput assembler, les processeurs de shader, et les autres circuits. Le processeur de commande est impliqué dans la répartition du travail de manière générale, mais s'occupe surtout du rendu 3D et 2D. Il y aura un chapitre spécialisé sur le fonctionnement du processeur de commande, qui parlera de son rôle en général.

Outre le rendu 3D, les cartes graphiques modernes sont utilisées dans le calcul scientifique, pour accélérer les applications d'IA et bien d'autres. L'usage d'une carte graphique pour autre chose que le rendu 3D porte le nom de GPGPU (General Processing GPU). Le GPGPU est utilisé pour diverses tâches : calcul scientifique, tout ce qui implique des réseaux de neurones, imagerie médicale, etc. De manière générale, tout calcul faisant usage d'un grand nombre de calculs sur des matrices ou des vecteurs est concerné.

En soi, le GPGPU est assez logique : une carte graphique est un monstre de puissance qu'il vaut mieux utiliser dès que possible. Bien que conçues spécifiquement avec le rendu 3D en tête, elles n'en restent pas moins des processeurs multicœurs SIMD/VLIW assez complexes et puissants, qu'on peut parfaitement utiliser en tant que processeur multicœur.

Pour le GPGPU, la programmation est assez simple. L'idée est d'écrire des shaders qui font des calculs génériques, à savoir qu'ils ne travaillent pas sur des pixels ou des vertices provenant du rastériseur ou de l'input assembler. Les données manipulées par le shader sont simplement lues ou écrites en mémoire, directement, sans passer par le moindre circuit fixe. Les shaders deviennent alors des programmes comme les autres, les processeurs de shaders sont utilisés comme n'importe quel processeur SIMD normal. Reste à répartir le travail sur les différents processeurs de shaders. Il suffit de programmer un shader qui effectue le calcul voulu et de configurer le GPU en mode GPGPU.

La répartition du travail en GPGPU n'est pas celle du mode graphique

modifier

En mode GPGPU, le shader s'exécute directement, il n'y a pas de passage par le rasterizeur, les ROP ou l'input assembler, ou tout autre circuit fixe impliqué dans le rendu 3D. Il s'agit donc d'un mode différent du mode graphique. La répartition du travail en GPGPU et en mode graphique n'est pas du tout la même.

Du point de vue du GPGPU, l'architecture d'une carte graphique récente est illustrée ci-dessous. Les processeurs/cœurs sont les rectangles en bleu/rouge, avec des unités de calcul SIMD en bleu et des unité de calcul pour les calculs complexes (trigonométriques, racines carrées, autres) en rouge. La hiérarchie mémoire est indiquée en vert. Le tout est alimenté par un processeur de commande spécialisé, le Thread Execution Control Unit en jaune, qui répartit les différentes instances des shader sur les différents processeurs.

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

Les anciennes cartes graphiques se débrouillaient avec des processeurs de commande optimisés pour le rendu graphique, ce qui était sous-optimal. Mais de nos jours, les processeurs de commande se sont améliorés et sont capable de gérer des commandes de calcul génériques, qui n'utilisent que les processeurs de shaders et contournent les circuits fixes dédiés au rendu graphique.

Un premier point important est que certaines cartes graphiques ont des processeurs de commande séparés pour le GPGPU et le mode graphique. Un exemple est celui de l'architecture GCN d'AMD, qui disposait d'un processeur de commande pour le mode graphique, avec plusieurs processeurs de commande spécifiques au GPGPU. Les processeurs de commande spécifique au GPGPU étaient appelés des ACE, et il y en avait 8, 16 ou 32 selon l'architecture, le nombre ayant augmenté au cours du temps.

 
GCN command processing

Les cartes graphiques modernes peuvent gérer un rendu 3D simultané avec des calculs GPGPU. Les deux ont lieu en même temps, dans des processeurs de shaders séparés. Mais pour ne pas se marcher sur les pieds, le GPU dispose de mécanismes compliqués de répartition, mais aussi de priorité. Sur les cartes graphiques avant l'architecture AMD RDNA 1, les tâches de GPGPU avaient la priorité sur le rendu graphique. Maintenant, les cartes AMD modernes gérent un système de priorité plus complexe. De plus, elles ont la possibilité de suspendre complétement l'exécution d'un shader pour passer à une tâche graphique/GPGPU, ou autre, histoire faire passer avant tout les calculs prioritaires.

La répartition du travail pour le GPGPU

modifier

En GPGPU, un shader s'exécute sur des regroupements de données bien connus des programmeurs : des tableaux. Les tableaux peuvent être de simples tableaux, des matrices ou tout autre tableau, peu importe. Le processeur envoie à la carte graphique un ensemble comprenant un shader à exécuter, ainsi que les données/tableaux à manipuler. Les tableaux ont une taille variable, mais sont presque toujours de très grande taille, au moins un millier d’éléments, parfois un bon million, si ce n'est plus.

Il faut préciser que la terminologie du GPGPU est quelque peu trompeuse. Dans la terminologie GPGPU, un thread correspond à l'exécution d'un shader sur une seule donnée, un seul entier/flottant provenant du tableau. Beaucoup de monde s'imagine que les processeurs de shader exécutent des threads, qui sont regroupés à l'exécution en warps par le matériel. Le regroupement fait que tous les threads s'exécutent de manière synchrone sur des données différentes, avec un program counter partagé par tous les threads. Si on sait peu de choses sur la manière dont fonctionnent réellement les GPU modernes, ce n'est vraisemblablement pas ce qui se passe. Il est plus correct de dire qu'un GPU moderne est une sorte de processeur multicœurs amélioré, qui gère des tableaux/vecteurs de taille variable, mais les découpe en vecteurs de taille fixe à l'exécution et répartit le tout sur des processeurs SIMD.

Le processus de répartition du travail est globalement le suivant. Le processeur de commande reçoit une commande de calcul, qui précise quel shader exécuter, et fournit l'adresse de plusieurs tableaux, ainsi que des informations sur le format des données (entières, flottantes, tableau en une ou deux dimensions, autres). Le processeur de commande se charge de découper le ou les tableaux en vecteurs de taille fixe, que le processeur peut gérer. Par exemple, si un processeur SIMD gère des vecteurs de 32 entiers/flottants, alors le tableau est découpé en morceaux de 32 entiers/flottants, et chaque processeur exécute une instance du shader sur des morceaux de cette taille.

La répartition du travail telle que définie par CUDA

modifier

Pour les GPU NVIDIA, le processus de découpage n'est pas très bien connu. Mais on peut en avoir une idée en regardant l'interface logicielle utilisée pour le GPGPU. Chez NVIDIA, celle-ci s'appelle CUDA et ses versions donnent une idée de comment le découpage s'effectue. Premièrement, les shaders sont appelés des kernels. Les tableaux de taille variable sont appelés des grids. Les données individuelles sont appelées, de manière extrêmement trompeuse, des threads. Rappelons qu'un thread est, pour simplifier, censé désigner un programme en cours d'exécution, pas des données entières/flottantes regroupées dans un tableau. Aussi, pour éviter toute confusion, je vais renommer les thraeds CUDA en scalaires.

Les grids sont eux-même découpés en thread blocks, qui contiennent entre 512 et 1024 données entières ou flottantes, 512 et 1024 scalaires. La taille, 512 ou 1024, dépend de la version de CUDA utilisée, elle-même liée au modèle de carte graphique utilisé. Plutôt que d'utiliser le terme thread blocks, je vais parler de bloc de scalaires. Le bloc de scalaire est une portion d'un tableau, il correspond donc à un bloc de mémoire, à une suite d'adresse consécutives. Il a donc une adresse de départ.

Chaque scalaire d'un bloc de scalaire a un indice qui permet de déterminer son adresse, qui est calculé par le processeur de commande. Le calcul de l'indice peut se faire de différentes manières, suivant que le tableau soit un tableau unidimensionnel (une suite de nombre) ou bidimensionnel( une matrice). CUDA gère les deux cas et les dernières cartes graphiques gèrent aussi des tableaux à trois dimensions. Le calcul de l'adresse d'un scalaire se fait en prenant l'adresse de départ du bloc de scalaire, et la combinant avec les indices.

 
Découpage des tableaux avec CUDA.

Un bloc de scalaires s'exécute sur un processeur de shader, qui est aussi appelé un streaming multiprocessor (terme encore une fois trompeur, vu qu'il s'agit d'un processeur unique, bien que multithreadé. Une fois lancé sur un processeur de shader, il y reste définitivement : il ne peut pas migrer sur un autre processeur en cours d'exécution. Un processeur de shader peut exécuter plusieurs instances de shaders travaillant sur des bloc de scalaires différents. Dans le meilleur des cas, il peut traiter en parallèle environ 8 à 16 bloc de scalaires différents en même temps.

 
Exécution des thread blocks sur les processeurs de shaders.

Le bloc de scalaires est découpé en vecteurs d'environ 32 éléments. Les vecteurs en question sont appelés des warps dans la terminologie NVIDIA/CUDA, des wavefronts dans la terminologie AMD. Ces termes sont aussi utilisés pour décrire non pas les données, les vecteurs, mais aussi l'instance du shader qui s'occupe de faire des calculs dessus. La terminologie est donc assez confuse. Un warp/wavefront est donc en réalité un thread, un programme, une instance de shader, qui manipule des vecteurs SIMD de 32 éléments. Il y a le même problème avec le terme bloc de scalaires, qui désigne aussi bien les données que les instances du shader qui les manipule.

Cela fait en tout 16 à 32 vecteurs suivant la version de CUDA utilisée. Les 16 à 32 warps sont exécutés en même temps sur le processeur de shader, via multithreading matériel. Un warp exécute des instructions SIMD sur des vecteurs de taille fixe, le processeur ne connait que des vecteurs et des warps/wavefronts, il ne connait pas de shader qui travaille sur des données uniques non-SIMD. Le découpage en warps est encore une fois le fait du processeur de commande. Même le processeur de commande ne traite jamais de thread sur des données uniques non-SIMD, il gère des tableaux de taille variable.

Pour résumer, plus un GPU contient de processeurs de shaders, plus le nombre de blocs de scalaires qu'il peut traiter en même temps est important. Par contre, la taille des warps ne change pas trop et reste la même d'une génération sur l'autre. Cela ne signifie pas que la taille des vecteurs reste la même, mais elle est assez contrainte.