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