Aller au contenu

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

Un livre de Wikilivres.

Un GPU plusieurs processeurs de shaders, chacun traitant plusieurs sommets/pixels à la fois. La répartition du travail sur plusieurs processeurs de shaders est un vrai défi sur les cartes graphiques actuelles. La répartition du travail sur plusieurs processeurs de shaders est le fait du processeur de commande, un circuit de la carte graphique. Ce n'est pas son seul rôle, mais c'est clairement une fonctionnalité très importante que prend en charge le processeur de commande.

La répartition du travail pour le GPGPU

[modifier | modifier le wikicode]

Avant de voir ce qu'il en est pour le rendu 3D, nous allons faire un détour par les fonctionnalités dites de GPGPU. Outre le rendu 3D, les cartes graphiques modernes sont utilisées pour accélérer des calculs scientifiques, tout ce qui implique des réseaux de neurones, de l'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é.. L'usage d'une carte graphique pour autre chose que le rendu 3D porte le nom de GPGPU (General Processing GPU). En soi, le GPGPU est assez logique : les processeurs de shaders, bien que conçus avec le rendu 3D en tête, n'en restent pas moins des processeurs multicœurs SIMD/VLIW assez puissants.

Si nous voyons le GPGPU avant le rendu 3D, c'est pour une raison simple : la répartition du travail sur les processeurs de shaders est alors nettement plus simple. En GPGPU, les shaders font des calculs génériques, à savoir qu'ils ne travaillent pas sur des pixels ou des vertices. Ils n'ont donc pas à communiquer avec le rastériseur ou l'input assembler, ils ne lisent même pas de textures dans la RAM. Les processeurs de shaders communiquent seulement avec la mémoire vidéo, mais pas avec le moindre circuit fixe. La répartition du travail en GPGPU est donc beaucoup plus simple qu'en mode graphique, le processeur de commande a nettement moins de travail.

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

[modifier | modifier le wikicode]

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, le bleu et le rouge correspondant à des circuits de calcul différents. La hiérarchie mémoire est indiquée en vert. Le tout est alimenté par un processeur de commande, en jaune, ici appelé le Thread Execution Control Unit.

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, ayant chacun plusieurs unités de calcul appelées malencontreusement "processeurs de threads". 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.

En GPGPU, un shader s'exécute sur des regroupements de données bien connus des programmeurs : des tableaux. Pour rappel, un tableau est un ensemble d'entiers ou de flottants qui sont consécutifs en mémoire RAM. Les tableaux peuvent être de simples tableaux, des matrices, peu importe. Le processeur envoie à la carte graphique un shader à exécuter et les 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.

Le processus de répartition du travail est globalement le suivant. Le processeur de commande reçoit une commande de calcul GPGPU, 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 découpe les tableaux en vecteurs de taille fixe, qu'un processeur de shader 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.

Cependant, il faut tenir compte que les processeurs de shader sont multithréadés. Ils peuvent gérer plusieurs threads, qui sont exécutés selon les besoins. Si un thread est bloqué par un accès mémoire, un autre thread prend la relève. Un processeur de shader permet d'exécuter "en même temps" entre 8 et 64 threads. La conséquence que l'on peut envoyer plusieurs morceaux de tableau sur un processeur de shader. Chaque morceau de tableau est combiné avec un shader pour former un thread, et l'on envoie 8 à 64 de ces threads sur un même processeur de shader.

Pour résumer, on découpe le travail en morceaux de taille identique, qu'on envoie à chaque processeur de shader. 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.

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 scalaire, 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, mais ce n'est certainement pas ce qui se passe.

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

[modifier | modifier le wikicode]

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. Aussi, pour éviter toute confusion, je vais renommer les threads 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, 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 sa position dans le tableau, 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.

Les processeurs de shader sont appelés des streaming multiprocessor, terme encore une fois trompeur. Une fois lancé sur un processeur de shader, le shader lit un bloc de scalaire et l'utilise pour faire ses calculs. 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 entiers/flottants, appelés des warps dans la terminologie NVIDIA/CUDA, des wavefronts dans la terminologie AMD. Avec 512/1024 éléments par bloc de scalaire, découpé en warps de 32, cela donne 16 à 32 warps suivant la version de CUDA utilisée. Le découpage en warps est encore une fois le fait du processeur de commande.

Le terme warp est aussi utilisé pour décrire l'instance du shader qui fait des calculs avec un warp. 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. Les 16 à 32 warps sont exécutés en même temps sur le processeur de shader, via multithreading matériel, à savoir que le processeur les exécute à tour de rôle. Un warp exécute des instructions SIMD sur des vecteurs de 32 éléments, donc de taille fixe.

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.

La répartition avec une file de threads

[modifier | modifier le wikicode]

Pour résumer, les tableaux sont découpés en morceaux et chaque morceau est combiné avec un shader pour former un thread. reste à répartir ces threads sur les processeurs de shaders. La répartition la plus simple est la suivante : le premier morceau va dans le premier processeur de shader, le second morceau va dans le second processeur de shader, etc. En clair, un simple algorithme du tourniquet. Si elle été utilisée sur les anciens GPU, notamment sur les cartes graphiques SGI des années 80-90, ce n'est pas celle qui est utilisée aujourd'hui. A la place, la répartition demande une coopération entre le processeur de commande et les processeurs de shaders.

Les GPU modernes incorporent une file de threads, entre le processeur de commande et les processeurs de shaders, qui sert de file d'attente. Le processeur de commande découpe les tableaux en threads, qui sont ajoutés dans cette file d'attente. Les processeurs de shader récupèrent ensuite les threads disponibles dans cette file d'attente. Le processeur de commande remplit la file de thread tant que celle-ci n'est pas pleine. Et la file de thread se vide quand un processeur de shader est libre, qu'il a finit de calculer le thread précédent. La répartition est alors dynamique et exploite au mieux les processeurs de shader.

Prenons l'exemple d'un GPU avec une file de thread capable de mémoriser 32 threads, et 16 processeurs de shader. Soumettons un tableau de 24 éléments. Le processeur de commande remplit la file de thread avec 16 threads. Les processeurs de shader lisent alors chacun un thread, ce qui fait que la file d'attente se vide de 16 threads, il n'en reste que 8. Une fois qu'un shader a finit son travail, il lit un thread dans la file d'attente, ce qui fait qu'elle se vide un thread après l'autre.

L'exécution simultanée des commandes

[modifier | modifier le wikicode]

Maintenant, regardons ce qui se passe quand on envoie deux commandes successives. Prenons deux commandes de 24 threads chacune, avec 16 processeurs de shaders. Pour simplifier, les processeurs de shader vont d'abord exécuter les 16 threads de la première commande, et on va supposer qu'ils vont tous se terminer quasiment en même temps. Le processeur de commande remplit alors la file de commande : en plus des 8 threads restants, il rajoute 8 threads provenant de la commande suivante. Les processeurs de shader exécutent alors les 16 commandes dans la file : la moitié vient de la première commande, l'autre vient de la seconde commande.

En clair, la file de thread permet une forme de "pipeline", un terme connu de ceux qui ont déjà lu un cours d'architecture des ordinateurs. L'idée est que l'on peut lancer une nouvelle commande alors que la précédente n'est pas terminée. Il s'agit de l'idée générale, mais les détails peuvent être assez surprenants. Par exemple, si on veut exécuter pleins de commandes très petites, elles peuvent s'exécuter en parallèle dans des processeurs de shader séparés. Par exemple, avec 32 processeurs de shader, vous pouvez exécuter 16 commandes de un thread chacune. Ou encore, une commande lancée récemment peut se terminer avant une commande plus ancienne. Bref : la file de thread permet de lancer plusieurs commandes l'une après l'autre, mais elles peuvent s’exécuter en même temps et se terminer dans le désordre.

Tout fonctionne à la perfection tant que les commandes de calcul sont indépendantes. Mais il arrive qu'une commande prenne en entrée le résultat d'une commande précédente. Dans ce cas, lancer les deux commandes en même temps peut poser problème. La seconde commande peut alors tenter de lire un résultat qui n'a pas encore été calculé. Et il ne s'agit là que d'un cas particulier, mais de nombreuses dépendances assez complexes entre commandes existent. De telles dépendances imposent que la première soit intégralement terminée avant que la seconde démarre. On parle alors de partial pipeline flush.

Pour gérer cela, le processeur de commande supporte des commandes de synchronisation. Les plus simples, les commandes FLUSH empêchent le démarrage d'une commande tant que les précédentes sont en cours. Elles empêchent le processeur de commande d'ajouter des threads dans la file de thread, du moins tant que celle-ci n'est pas entièrement vide, et aussi tant que les processeurs de shader sont occupés. Ces commandes de synchronisation sont aussi appelées des barrières GPU. le terme indique bien qu'elle séparent le flux de commandes en deux.

Les barrières GPU ont un cout en performance, car elles empêchent d'exécuter plusieurs commandes en même temps. Mais elles sont nécessaires. Par exemple, reprenons l'exemple de deux commandes de 24 threads chacune, séparées par une barrière GPU, toujours avec 16 processeurs de shader. Le GPU va d'abord exécuter les 16 premiers threads. Puis, il va exécuter les 8 threads restants de la première commande, mais pas plus. La barrière GPU l'empeche d'exécuter les threads de la commande suivante. C'est seulement ensuite qu'il lancera les 16 threads de la seconde commande, puis les 8 restants. Cela a pris plus de temps que d'exécuter les deux commandes en même temps.

D'autres barrières GPU sont plus précises. Elles empêchent le démarrage d'une nouvelle commande tant que la commande précédente n'a pas atteint un certain stade de son exécution. Elles permettent de gagner en performance en démarrant la commande suivante au plus tôt. De telles commandes sont des commandes du style "attend que le registre de statut numéro N contienne la valeur adéquate avant de démarrer la commande suivante". Une alternative réserve une adresse mémoire, dans laquelle la commande précédente écrit une valeur prédéterminée pour dire qu'elle a finit.

La répartition du travail pour le rendu graphique

[modifier | modifier le wikicode]

Après avoir vu le cas du GPGPU, nous allons voir le cas du rendu 3D. La différence est que les commandes GPGPU sont remplacées par des commandes 3D, qui demande d'afficher un objet. Du point de vue de l'API 3D, elles correspondent grossièrement soit à un draw call, soit à un changement de render state. Aussi, nous ferrons parfois la confusion entre les deux, bien que ce soit techniquement une grosse simplification, plus proche de l'erreur ou de la confusion que de l'abus de langage.

Les commandes graphiques

[modifier | modifier le wikicode]

Les commandes graphiques sont différentes des commandes de calcul, mais elles fonctionnent sur le même principe. La différence est qu'une commande graphique demande d'afficher un objet 3D, les draw call affichant une image objet par objet. Une commande graphique contient bien un tableau de données : un tableau de triangle qui correspond à l'objet à afficher. Cependant, elle contient aussi une liste de texture et une liste de shaders. Le tableau de triangles est découpé en threads qui sont envoyés aux processeurs de shaders.

Dans les GPU rudimentaires, le processeur de commande exécute les commandes 3D dans l'ordre, une par une. Il attend qu'une commande soit terminée avant de démarrer la suivante. Il s'agit d'une technique simple, conservatrice, mais pas forcément très performante. Elle était surtout utilisée sur les GPU non-programmables, avant l'invention des shaders. Sur les GPU modernes, les choses sont très différentes.

Plus haut, on a vu que le GPU peut exécuter plusieurs commandes de calcul consécutives en même temps. Et il se trouve que c'est la même chose avec les commandes de rendu 3D/2D. Par exemple, si une commande simple n'utilise que 3 processeurs de shaders sur 8, la commande suivante peut être lancée pour occuper les 5 processeurs de shader restants. Mais cela n'est possible que si les circuits fixes sont capables d'accepter une seconde commande. Si la première commande sature les circuits fixe, le lancement de la seconde commande sera empêché.

Concrétement, du point de vue de l'API graphique, le GPU peut exécuter plusieurs draw call en même temps. Pire que ca, un draw call lancé après un autre peut finir avant ! Les draw call sont donc traités dans un désordre tout relatif, mais loin de ressembler à un ordre strict. Et la conséquence, c'est que les problèmes de dépendances vus plus haut reviennent.

Par exemple, imaginez qu'un jeu écrive une shadowmap dans une texture, puis l'utilise dans un algorithme d'éclairage. Une première commande calcule et écrit la shadowmap, une seconde commande exécute l'algorithme d'éclairage. Il est interdit de démarrer la seconde commande tant que la première commande n'a pas calculé son résultat, la shadowmap n'est pas encore prête. Et une barrière GPU est alors nécessaire. Elle est ajoutée par le driver, ou par le programmeur s'il utilise une API 3D récente (DirectX 12, Vulkan).

Un autre exemple survient quand deux draw calls consécutifs utilisent des render state différents. Dans ce cas, le GPU voit deux commandes de rendu, avec une commande de changement d'état entre les deux. La commande de changement d'état fait alors deux choses : le changement d'état, mais aussi une barrière GPU. Concrètement, le processeur de commande ne démarre le second draw call que quand le changement d'état est terminé.

La répartition entre pixel shaders et vertex shaders

[modifier | modifier le wikicode]

Avant de poursuivre, faisons une première remarque. Les premiers GPU avaient des processeurs séparés pour les vertex shaders et les pixel shaders. Pour donner un exemple, la Geforce 6800 avait 16 processeurs pour les pixel shaderset 6 processeurs pour les vertex shaders. La raison est que DirectX 9.0 et OpenGL avaient des jeux d'instruction différents pour les vertex et pixel shaders. Par exemple, les pixels shaders doivent accéder aux textures, pas les vertex shaders. Les GPU de l'époque ont beaucoup plus de processeurs de pixel shaders que de processeurs de vertex shaders, en raison du phénomène d'amplification mentionné plus haut.

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

Un détail important est qu'un triangle est affiché sur un ou 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. La conséquence est qu'il y a plus de travail à faire sur les pixels que sur les sommets. C'est un phénomène d'amplification, qui explique qu'il y a plus de processeurs pour les pixel shaders que pour les vertex shaders.

Un problème de ces GPU est que la répartition entre puissance entre vertex et pixel shaders est fixe. Mais 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. La répartition idéale est variable d'un jeu vidéo à l'autre, voire d'un niveau de JV à l'autre, et ces GPU en étaient loin.

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

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.
Carte 3D avec pixels et vertex shaders unfifiés.

La répartition du travail n'est pas exactement la même dans les deux cas, mais les grandes lignes sont les mêmes. Dans le cas le plus simple, le processeur de commande regarde s'il y a des processeurs de shaders libres. Si c'est le cas, il leur envoie un nouveau thread pour calculer de nouveaux triangles. Il y a bien des problématiques liées à la présence de linput assembler, mais pas plus. Plus complexe, il peut y avoir une file de threads, que le processeur de commande remplit au mieux.

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

[modifier | modifier le wikicode]

La répartition des sommets sur les processeurs de shaders n'est pas un problème. Le tableau de triangles est découpé en threads qui sont envoyés aux processeurs de shaders (remplacer par les unités de T&L sur les anciens GPU). Il faut noter que le découpage du tableau de triangle en threads est le fait de l'input assembler, pas du processeur de commande. Mais tout ce qui se passe après est plus complexe. Notamment, le rastériseur et les ROPs sont des points de convergence où convergent plusieurs sommets/pixels.

Parallélisme dans une carte 3D

Pour gérer cette convergence, les cartes graphiques mettent en attente les sommets/pixels dans des mémoire FIFO, situées un peu partout dans le pipeline. Elles ont le même rôle que la file de thread des commandes de calcul, sauf qu'elles mémorisent des blocs de pixels/triangles.

La FIFO la plus proche d'une file de thread est celle située en sortie de l'input assembler, ce qui colle bien avec le fait que c'est lui qui découpe le tableau de triangle en threads. Il y aussi une FIFO en entrée et en sortie du rastériseur. Celle en entrée accumule des sommets et les regroupe en triangles (c'est la phase d'assemblage de primitive qu'on détaillera dans quelques chapitres). Celle en sortie contient des pixels à destination des pixels shaders. Enfin, il y a une FIFO en entrée des ROP qui accumule des pixels à enregistrer en mémoire.

Les processeurs de shaders lisent dans ces mémoires FIFO pour récupérer du travail à faire. La présence des mémoires FIFO désynchronise les é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, la première étape n'a pas à attendre que la seconde soit disponible pour lui envoyer des données. Sauf si la mémoire tampon est pleine, car elle ne peut plus accepter de nouveaux sommet/pixels, évidemment.

Intuitivement, on penserait que les FIFO sont utilisées de la même manière que la file de thread, à savoir que tout processeur de shader libre peut récupérer des pixels dedans. Par exemple, un processeur de pixel shader peut lire dans la FIFO en sortie du rastériseur, un processeur de vertex shader peut lire dans celle en sortie de l'input assembler, etc. Avec des shaders unifié, un processeur de shader peut lire les deux FIFOs précédentes. Une telle distribution dynamique permet 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.

Mais l'implémentation est cependant assez compliquée, pour diverses raisons. Par exemple, il faut prendre en compte le cas où plusieurs processeurs de shaders sont libre : les deux processeurs ne doivent pas récupérer le même thread, mais deux threads différents. Pour cela, il est possible d'utiliser une mémoire FIFO multiport. Une autre solution couple la mémoire FIFO à 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. Et tout cela demande beaucoup de circuits...

Dispatch des shaders sur plusieurs processeurs de shaders

Cependant, d'autres solutions permettent de fortement simplifier le design du GPU. La distribution statique envoie chaque sommet/pixel 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 position à 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.

Les GPU modernes mélangent distribution statique et dynamique. La raison est qu'ils disposent de plusieurs rastériseurs et de plusieurs ROP, pour ces questions de performance. Un GPU moderne est organisé en plusieurs Graphic Processing Units (terminologie NVIDIA), que nous abrévierons en GPC. Ils regroupent chacun : plusieurs processeurs de shaders, un rastériseur et un ROP. Les processeurs de shaders envoient leurs résultats au ROP et au rasteriseur dans le GPC, pas à ceux dans un autre GPC, ce qui est de la distribution statique. Par contre, le rastériseur utilise la distribution dynamique pour distribuer le travail sur les processeurs de shaders.

Graphic card architecture with unified shaders

L'exécution de commandes différentes : graphiques et GPGPU

[modifier | modifier le wikicode]

Les GPU modernes disposent de plusieurs processeurs de commandes, et donc de plusieurs files de commande. Et les chiffres peuvent monter assez haut. Par exemple, les GPU AMD utilisent un processeur de commande graphique, accompagné de plusieurs processeurs de commande dédiés 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. Et chacun gérait plusieurs files de commandes séparées ! Par exemple, les GPU AMD d'architecture GCN ont 8 processeurs de commande GPGPU pour 64 files de commandes GPGPU, auxquels il faut ajouter le processeur pour les commandes graphiques en plus ! Les GPU NVIDIA d'architecture Pascal disposent eux de 32 files de commande matérielles, mais dont 31 sont réservées aux GPGPU.

GCN command processing

Mais quelle est l'intérêt ? Pour le dire vite : remplir des processeurs de shader inutilisés. Il arrive que des processeurs de shaders soient inutilisés, soit parce que la commande en cours d'exécution n'en a pas besoin. Il est théoriquement possible de les remplir avec des threads provenant de la commande suivante. Cependant, ce n'est pas toujours possible. Par exemple, il se peut que la commande suivante soit une barrière GPU, qui bloque l'avancée des commandes suivantes. Ou encore, que la file de commande soit vide, car les commandes suivantes ne sont pas encore arrivées.

Une solution serait alors de chercher des commandes ailleurs, et de préférence des commandes capables de remplir des processeurs de shader. Mais où les trouver ?

L'exécution simultanée de plusieurs applications sur le GPU

[modifier | modifier le wikicode]

La solution la plus simple est assez évidente, quand on se rappelle du chapitre sur les API 3D, et notamment de la section de fin sur le partage du GPU entre plusieurs applications. Un GPU est aujourd'hui sollicité par plusieurs logiciels en même temps : il est possible de lancer un jeu vidéo en fenêtré, pendant que OBS ou un logiciel de Streaming capture l'écran, avec Firefox et Discord et un programme de cloud computing de type Folding@Home qui tournent en arrière-plan. Et toutes ces applications sont accélérées par le GPU.

Des situations de ce genre, où on doit partager le GPU entre plusieurs applications, sont assez courantes. Et c'est soit le pilote qui s'en charge, soit le GPU lui-même.

  • L'arbitrage logiciel est la méthode la plus simple : tout est fait en logiciel. Concrètement, le système d'exploitation et/ou le pilote de la carte graphique se chargent du partage du GPU. Dans les deux cas, tout est fait en logiciel.
  • L'arbitrage matériel délègue ce partage au GPU. Du moins en partie, car le système d'exploitation détermine quelle application a la priorité. L'avantage est que cela décharge le processeur d'une tâche assez lourde en calcul, pour la déporter sur le GPU. Sans compter que les mécanismes matériels d'arbitrage sont plus efficaces.

Sur Windows, avant l'arrivée du modèle de driver dit Windows Display Driver Model, il n'y avait pas d'arbitrage entre les applications. Il y avait une file de commande unique et les commandes étaient exécutées en mode "premier entré, premier sorti". Et ce n'était pas un problème car les seules applications qui utilisaient le GPU étaient des jeux vidéos fonctionnant en mode plein écran. Avec le modèle de driver Windows Display Driver Model (WDDM), l'arbitrage logiciel est apparu. Depuis 2020, avec l'arrivée de la Windows 10 May 2020 update, Windows supporte l'arbitrage matériel.

L'arbitrage matériel est implémenté grâce à la présence de plusieurs processeurs de commandes. L'idée est d'attribuer des priorités à chaque processeurs de commande. Et plus un processeur de commande est prioritaire, plus le GPU lui réserve de processeurs de shaders. Prenons l'exemple avec deux processeurs de commande, et donc deux files de thread, et 16 processeurs de shaders. Si la première file de thread a la priorité, le GPU lui réserve 14 processeurs de shaders sur 16, contre seulement 2 pour l'autre file.

Un point important est qu'en général, une seule application effectue un rendu 3D, typiquement un jeu vidéo en plein écran ou en fenêtré. Les autres applications utilisent plutôt le GPGPU ou des commandes de rendu 2D, qui utilisent uniquement les processeurs de shaders. En conséquence, pas besoin d'avoir plusieurs processeurs de commande généralistes. L'idéal est de n'avoir qu'un seul processeur de commandes graphiques, accompagné de pleins de processeurs de commandes dédiés au GPGPU. Cela permet d'exécuter des commandes graphiques et GPGPU en parallèle.

Les priorités étaient autrefois statiques, à savoir que certaines processeurs de commande avaient toujours la priorité sur tous les autres. En théorie, le processeur de commande graphique devrait avoir la priorité sur les autres. Mais il y a des exceptions. Par exemple, sur les cartes graphiques avant l'architecture AMD RDNA 1, c'était l'inverse : les tâches de GPGPU avaient la priorité sur le rendu graphique. Maintenant, les GPU récents ont un système de priorité plus complexe, dynamique, qui choisit les priorité en fonction des besoins.

L'usage de plusieurs processeurs de commande pour le rendu 3D

[modifier | modifier le wikicode]

Utiliser plusieurs applications est une première solution pour utiliser plusieurs files de commande. Mais avec un peu d'huile de coude, il est possible d'extraire plusieurs files de commande par application. le rendu 3D permet ce genre de choses, car de nombreux draw calls sont indépendants. Pour comprendre en quoi traiter plusieurs commandes peut être utile, je vais reprendre l'exemple décris sur cet article de blog, les liens sur l'ensemble des posts sur le sujet sont à la fin de ce chapitre.

En rendu 3D, il est fréquent que des draw call consécutifs soient dépendants, qu'ils doivent s'exécuter l'un après l'autre, sans recouvrement possible. Un exemple est celui des filtres de post-traitement comme le Bloom ou la profondeur de champ. Ces deux filtres se font en plusieurs étapes, qui se traduisent en plusieurs draw call consécutifs. Les draw calls d'un filtre sont dépendants, à savoir que chaque étape lit le résultat de l'étape précédente. Ils sont donc séparés par des barrières GPU, ce qui ruine rapidement les performances. De plus, leurs draw calls n'utilisent pas tous les processeurs de shaders.

Un filtre de bloom basique laisse des processeurs de shaders libre, qui pourraient être utilisés pour exécuter un autre filtre de post-traitement en parallèle, comme un filtre de profondeur de champ. Le problème est que la file de commande est séquentielle par nature. Une solution serait pour le moteur graphique de mélanger les draw call pour le filtre de bloom et ceux du filtre de profondeur de champ, mais ce serait assez compliqué pour les programmeurs. Une autre solution délègue le problème au matériel et à l'API.

L'idée est d'avoir deux files de thread : une pour le filtre de Bloom, une autre pour le filtre de profondeur de champ. Les processeurs de shaders peuvent prendre des commandes dans les deux files. S'il y a assez de processeurs de shaders de libre, ils peuvent piocher des commandes dans la seconde file de commande. Les deux files accumulent des commandes, séparées par des barrières GPU. Mais les barrières GPU se limitent à l'intérieur d'une file de commande, elles n'ont pas d'impact sur l'autre file de commande.

Mais qui dit deux files de commande dit : deux processeurs de commande et files de commandes ! Les deux processeurs de commande alimentent les deux files de thread, en piochant chacun dans une file de commande dédiée. Et la même logique vaut pour N processeurs de commandes : tant qu'il y a autant de files de thread et de files de commande, la logique fonctionne à l'identique. La seule contrainte est d'alimenter plusieurs files de commande, ce qui est le job du pilote du GPU. De plus, il faut ajouter des commandes de synchronisation entre files de commandes, qui permettent de synchroniser les deux files de commande. La plus simple force à attendre que les deux files de commandes soient vidées avant de démarrer une nouvelle commande.

Le support dans les API graphiques modernes

[modifier | modifier le wikicode]

Avant l'apparition des API modernes Vulkan et DirectX 12, l'usage de plusieurs files de commande était peu fréquent. En effet, les applications ne voyaient pas de file de commande, les API graphiques avaient juste des draw calls et les commandes graphiques étaient envoyées au pilote une par une. C'est ce dernier qui remplissait la file de commande avec des commandes matérielles. En pratique, le pilote pouvait utiliser une file de commande par application, guère plus. Il n'y avait aucun moyen d'utiliser plusieurs files de commande par application.

Pire que ça : un moteur graphique ne pouvait pas utiliser plusieurs cœurs. Les API graphiques de l'époque étaient séquentielles par nature, elles exécutaient les draw calls l'un après l'autre, et il ne pouvait y avoir qu'une seule instance par application. DirectX 11 a bien tenté d'ajouter des mécanismes pour multi-threader les moteurs graphiques, mais ils étaient difficiles à utiliser. Les jeux vidéo de l'époque étaient capables d'utiliser plusieurs cœurs, mais le moteur graphique n'en utilisait qu'un seul. Typiquement, le rendu du son était réalisé sur un cœur CPU, le reste sur un autre. Parfois, on séparait le moteur physique et le moteur graphique sur deux cœurs séparés, mais pas plus.

Pour aider les programmeurs, les API modernes Vulkan et DirectX 12, gèrent nativement plusieurs files de commandes, qui sont exposées au programmeur. Ce qui était autrefois le domaine du pilote du GPU a été déporté dans les API graphiques. Les commandes remplacent les draw calls mais fonctionnent plus ou moins de la même manière. Le programmeur a accès à une fonction pour créer une file de commande, une autre pour ajouter une commande dedans, et une pour envoyer la file de commande au pilote de GPU. Le programmeur peut remplir ces files de commande comme il le souhaite, tant que les commandes dans des files séparées sont indépendantes.

Il faut noter que ce sont des files de commandes graphiques, pas des files de commande matérielles. En clair, les files de commandes sont envoyées au pilote du GPU, qui les traduit en commandes matérielles. Et le GPU gère ses propres files de commandes matérielles. Il peut envoyer ses files de commandes séparément dans des processeurs de commande séparés, mais il peut décider d'accumuler toutes les commandes dans une seule file de commande matérielle si le GPU n'a qu'un seul processeur de commande, ou n'utiliser qu'une seule file de commande par application.

En théorie, ce système permet de réduire l'usage du processeur. Au lieu d'appeler le pilote de GPU à chaque draw call, il peut accumuler plein de draw call dans une file de commande et envoyer le tout en une seule fois au pilote. Il est donc possible de lancer un grand nombre de draw calls sans trop surcharger le CPU. Du moins en théorie, car le problème des draw call très petits que le GPU exécute trop vite reste présent. Mieux que ça, ce système permet de couper le moteur graphique en plusieurs threads, afin de l'exécuter sur plusieurs cœurs. Par exemple, deux threads peuvent créer deux files de commandes différentes qui sont exécutées en parallèle (si elles ne sont pas sérialisées par le pilote de GPU).

Tout complexifie la tâche du programmeur, vu qu'il doit faire le travail autrefois pris en charge par le pilote de GPU. L'intérêt est que cela permet au programmeur d'optimiser. Il peut utiliser plusieurs processeurs de commande par application, peut exécuter des commandes en parallèle pour remplir tous les processeurs de shaders, etc. Et surtout, il peut insérer des barrières GPU seulement quand elles sont nécessaires. Avec DirectX 11, le pilote de GPU avait tendance à être prudent. Il insérait des barrières GPU très souvent, et n'avait pas moyen de savoir lesquelles étaient réellement nécessaires et celles qui étaient juste inutiles. Il n'avait pas accès aux algorithmes des programmeurs, pour optimiser le tout. Les programmeurs ont la possibilité d'être plus efficaces, du moins s'ils sont assez compétents et qu'on leur laisse le temps.

Il faut impérativement que les files puissent exécuter des commandes séparément sans que cela pose problème. Il y a bien des barrières GPU inter-files, mais laissons cela de côté. Toujours est-il que les files de commandes en question accumulent des commandes graphiques, pas des commandes matérielles. Mais le pilote de la carte graphique reçoit ces files de commande graphique et les accumule dans ses propres files de commandes matérielles.

Avec DirectX 12, trois types différents de files de commande sont nativement supportés : GRAPHIC, COMPUTE et COPY. COPY correspond aux transferts DMA ou à des copies de données en VRAM, COMPUTE mémorise des commandes GPGPU, GRAPHIC est une file de commande de rendu 2D/3D. Du moins, c'est l'explication simplifiée. Car en réalité, les commandes GPGPU sont capables de faire tout ce que COPY permet, et les commandes GRAPHIC supportent tout ce que les commandes COMPUTE supportent. Les relations entre les trois sont inclusives : COPY est inclus dans COMPUTE, qui est lui-même inclus dans GRAPHIC.

Les applications envoient des files de commande COPY, COMPUTE et GRAPHIC au pilote de GPU, qui décide quoi en faire. Si le GPU le supporte, il envoie les commandes GRAPHIC au processeur de commandé généraliste, les commandes COMPUTE à ceux dédiés au GPGPU, les commandes COPY au contrôleur DMA. Mais il peut aussi envoyer transformer des commandes COMPUTE en commandes GRAPHICS ou des commandes CPY en COMPUTE ou GRAPHIC, si le besoin s'en fait sentir. Si le GPU n'a qu'un seul processeur de commande, les commandes sont simplement remises en série et envoyées au seul processeur de commande. C'est le cas sur les GPU Intel intégrés : ils ont un simple processeur de commande qui fait tout. Ils ne gèrent pas les transferts DMA, car ils sont reliés à la RAM système (mémoire unifiée).

Vulkan utilise un système différent. Vulkan permet de demander à la carte graphique combien elle a de processeurs de commande et quelles commandes ils supportent. Si un GPU n'a qu'un seul processeur de commande, Vulkan n'en verra qu'un et le code devra être adapté pour. En clair, la gestion des processeurs de commande est totalement délégué au programmeur. Il n'y a pas d'abstraction comme avec DirectX 12, mais une gestion fine du matériel.

Sources extérieures

[modifier | modifier le wikicode]

Pour compléter la lecture de ce chapitre, vous pouvez lire les 6 articles de blog suivants :