Les cartes graphiques/Les caches d'un processeur de shader
Dans ce chapitre, nous allons voir comment est organisée la mémoire d'un GPU, ou plutôt devrait-on dire les mémoires d'un GPU. Eh oui : un GPU contient beaucoup de mémoires différentes. Un GPU contient évidemment une mémoire vidéo de grande taille, séparée des processeurs de shader, mais pas que. Les processeurs de shaders intègrent aussi des mémoires plus petites, appelées des mémoires caches. Les processeurs intégrent tous des caches et les processeurs de shaders ne font pas exception. Cependant, les caches d'un GPU sont quelque peu particuliers et sont organisés différemment. La hiérarchie mémoire des GPUs est assez particulière, et nous allons voir en quoi dans ce qui suit.
Les caches spécialisés d'un GPU
[modifier | modifier le wikicode]Un point important est que les GPU sont dédiés au rendu 3D, et cette spécialisation se voit dans leurs mémoires caches. Les premières cartes graphiques avaient des caches spécialisés, avec des caches pour les textures, des caches de sommets, des caches pour le tampon de profondeur, etc. Ils n'avaient pas de caches généralistes, qui servent à stocker n'importe quel type de données. Les caches spécialisés étaient intégrés aux circuits fixes. Par exemple, le cache pour les textures est placé dans l'unité de texture, le cache de sommet dans l'input assembler, le cache du z-buffer dans les ROPs.
Le cache de textures
[modifier | modifier le wikicode]Le cache de textures, comme son nom l'indique, est un cache spécialisé dans les textures. Toutes les cartes graphiques modernes disposent de plusieurs unités de texture, qui disposent chacune de son ou ses propres caches de textures. Pas de cache partagé, ce serait peu utile et trop compliqué à implémenter.
De plus, les cartes graphiques modernes ont plusieurs caches de texture par unité de texture. Généralement, elles ont deux caches de textures : un petit cache rapide, et un gros cache lent. Les deux caches sont fortement différents. L'un est un gros cache, qui fait dans les 4 kibioctets, et l'autre est un petit cache, faisant souvent moins d'1 kibioctet. Mais le premier est plus lent que le second. Sur d'autres cartes graphiques récentes, on trouve plus de 2 caches de textures, organisés en une hiérarchie de caches de textures similaire à la hiérarchie de cache L1, L2, L3 des processeurs modernes.
Notons que ce cache interagit avec les techniques de compression de texture. Les textures sont en effet des images, qui sont donc compressées. Et elles restent compressées en mémoire vidéo, car les textures décompressées prennent beaucoup plus de place, entre 5 à 8 fois plus. Les textures sont décompressées lors des lectures : le processeur de shaders charge quelques octets, les décompresse, et utilise les données décompressées ensuite. Le cache s'introduit quelque part avant ou après la décompression.
On peut décompresser les textures avant de les placer dans le cache, ou laisser les textures compressées dans le cache. Tout est une question de compromis. Décompresser les textures dans le cache fait que la lecture dans le cache est plus rapide, car elle n'implique pas de décompression, mais le cache contient moins de données. À l'inverse, compresser les textures permet de charger plus de données dans le cache, mais rend les lectures légèrement plus lentes. C'est souvent la seconde solution qui est utilisée et ce pour deux raisons. Premièrement, la compression de texture est terriblement efficace, souvent capable de diviser par 6 la taille d'une texture, ce qui augmente drastiquement la taille effective du cache. Deuxièmement, les circuits de décompression sont généralement très rapides, très simples, et n'ajoutent que 1 à 3 cycles d'horloge lors d'une lecture.
Les anciens jeux vidéo ne faisaient que lire les textures, sans les modifier. Aussi, le cache de texture des cartes graphiques anciennes est seulement accessible en lecture, pas en écriture. Cela simplifiait fortement les circuits du cache, réduisant le nombre de transistors utilisés par le cache, réduisant sa consommation énergétique, augmentait sa rapidité, etc. Mais les jeux vidéos 3D récents utilisent des techniques dites de render-to-texture, qui permettent de calculer certaines données et à les écrire en mémoire vidéo pour une utilisation ultérieure. Les textures peuvent donc être modifiées et cela se marie mal avec un cache en lecture seule.
Rendre le cache de texture accessible en écriture est une solution, mais qui demande d'ajouter beaucoup de circuits pour une utilisation somme toute peu fréquente. Une autre solution, plus adaptée, réinitialise le cache de textures quand on modifie une texture, que ce soit totalement ou partiellement. Une fois le cache vidé, les accès mémoire ultérieurs n'ont pas d'autre choix que d'aller lire la texture en mémoire et de remplir le cache avec les données chargées depuis la RAM. Les données de texture en RAM étant les bonnes, cela garantit l’absence d'erreur.
- Ces deux techniques peuvent être adaptées dans le cas où plusieurs caches de textures séparées existent sur une même carte graphique. Les écritures doivent invalider toutes les copies dans tous les caches de texture. Cela nécessite d'ajouter des circuits qui propagent l'invalidation dans tous les autres caches.
Les caches de constante
[modifier | modifier le wikicode]Un shader a besoin de certaines "constantes" pour faire son travail. Les constantes en question sont d'accès peu fréquent, qui se limite souvent à un accès au début de chaque instance de shader, guère plus. Au premier abord, dédier un cache à de telles constantes ne parait pas très utile, vu qu'elles ne semblent pas réutilisées. Mais c'est oublier un point important : toutes les instances du shader manipulent ces constantes, et il y en a souvent plusieurs qui s'exéceutn à tour de rôle, si ce n'est en même temps ! Pour profiter du partage des constantes entre instances d'un shader, les GPU incorporent des caches de constantes. Ainsi, quand un shader lit une donnée, elle est chargée dans le cache de constante, ce qui fait que les autres instances liront ces constantes depuis le cache et non depuis la VRAM.
Les caches de constante sont séparés des autres caches de données, car ce sont des données peu fréquemment utilisées, qui sont censées être évincées en priorité du cache de données, qui privilégie les données fréquemment lues/écrites. Avec un cache séparé, les constantes restent dans le cache. Au passage, ce cache de constante a des chances d'être partagé entre plusieurs cœurs, des cœurs différents ayant de fortes chances d’exécuter des instances différentes d'un même shader.
Les caches généralistes
[modifier | modifier le wikicode]Les GPUs récents contiennent des caches généralistes, qui ne sont spécialisés dans le rendu graphique. Leur existence se justifie par le fait que les GPU sont de plus en plus utilisés pour du calcul généraliste (scientifique, notamment), à savoir qu'ils exécutent des compute shaders qui manipulent des données arbitraires. Et de tels compute shaders sont parfois utilisés pour du rendu 3D, pour exécuter des algorithmes d'élimination des pixels cachés, ou des algorithmes de rendu assez complexes.
Les caches généralistes des GPU modernes ressemblent à ceux des CPU, avec une hiérarchie de caches. Pour rappel, les processeurs multicœurs modernes ont souvent trois à quatre niveaux de caches, appelés les caches L1, L2, L3 et éventuellement L4. Les GPU ont une organisation similaire, sauf que le nombre de cœurs est beaucoup plus grand que sur un processeur moderne.
- Pour le premier niveau, on a deux caches L1 par cœur/processeur : un cache pour les instructions et un cache pour les données.
- Pour le second niveau, on a un cache L2 qui peut stocker indifféremment données et instruction et qui est partagé entre plusieurs cœurs/processeurs.
- Le cache L3 est un cache partagé entre tous les cœurs/processeurs.

Les caches d'instruction
[modifier | modifier le wikicode]Les caches d'instruction des GPU sont adaptés aux contraintes du rendu 3D. Le principe du rendu 3D est d'appliquer un shader assez simple sur un grand nombre de données. Les shaders sont donc des programmes assez légers, qui ont peu d'instructions. Les caches d'instructions des GPU sont généralement assez petits, quelques dizaines ou centaines de kilooctets. Et malgré cela, il n'est pas rare qu'un shader tienne tout entier dans le cache d'instruction.
La seconde caractéristique est qu'un même programme s’exécute sur beaucoup de données. Il n'est pas rare que plusieurs processeurs de shaders exécutent le même shader. Aussi, certains GPU partagent un même cache d’instruction entre plusieurs processeurs de shader, comme c'est le cas sur les GPU AMD d'architecture GCN où un cache d'instruction de 32 kB est partagé entre 4 cœurs.
Les caches de données
[modifier | modifier le wikicode]Pour les caches de données, il faut savoir qu'un shader a peu de chances de réutiliser une donnée qu'il a chargé précédemment. Les processeurs de shaders ont beaucoup de registres, ce qui fait que si accès ultérieur à une donnée il doit y avoir, elle passe généralement par les registres. Cette faible réutilisation fait que les caches de données ne sont pas censé être très utiles. Il y a cependant des exceptions, qui expliquent que les cartes graphiques incorporent un cache de texture et un cache de sommet (pour le tampon de sommet).
Il faut noter que sur la plupart des cartes graphiques modernes, les caches de données et le cache de texture sont un seul et même cache. Même chose pour le cache de sommets, utilisé par les unités géométrique, qui est fusionné avec les caches de données. La raison est que une économie de circuits qui ne coute pas grand chose en termes de performance. Rappelons que les processeurs de shaders sont unifiés à l'heure actuelle, c'est à dire qu'elles peuvent exécuter pixel shader et vertex shader. Au lieu d'incorporer un cache de sommets et un cache de textures, autant utiliser un seul cache qui sert alternativement de cache de vertex et de cache de texture, afin d'économiser des circuits.
La mémoire partagée : un local store
[modifier | modifier le wikicode]En plus d'utiliser des caches, les GPU modernes utilisent des local stores, aussi appelés scratchpad memories. Ce sont des mémoires RAM intermédiaires entre la RAM principale et les registres. Ces local stores peuvent être vus comme des caches, mais que le programmeur doit gérer manuellement. Dans la réalité, ce sont des mémoires RAM très rapides mais de petite taille, qui sont adressées comme n'importe quelle mémoire RAM, en utilisant des adresses directement.

Sur les GPU modernes, chaque processeur de shader possède un unique local store, appelée la mémoire partagée. Il n'y a pas de hiérarchie des local store, similaire à la hiérarchie des caches.

La faible capacité de ces mémoires, tout du moins comparé à la grande taille de la mémoire vidéo, les rend utile pour stocker temporairement des résultats de calcul "peu imposants". L'utilité principale est donc de réduire le trafic avec la mémoire centrale, les écritures de résultats temporaires étant redirigés vers les local stores. Ils sont surtout utilisés hors du rendu 3D, pour les applications de type GPGPU, où le GPU est utilisé comme architecture multicœurs pour du calcul scientifique.
L'implémentation des local store
[modifier | modifier le wikicode]Vous vous attendez certainement à ce que je dise que les local store sont des mémoires séparées des mémoires caches et qu'il y a réellement des puces de mémoire RAM distinctes dans les processeurs de shaders. Mais en réalité, ce n'est pas le cas pour tous les local store. Le dernier niveau de local store, la mémoire partagée, est bel et bien une mémoire SRAM à part des autres, avec ses propres circuits. Mais les cartes graphiques très récentes fusionnent la mémoire locale avec le cache L1.
L'avantage est une économie de transistors assez importante. De plus, cette technologie permet de partitionner le cache/local store suivant les besoins. Par exemple, si la moitié du local store est utilisé, l'autre moitié peut servir de cache L1. Si le local store n'est pas utilisé, comme c'est le cas pour la majorité des rendu 3D, le cache/local store est utilisé intégralement comme cache L1.
Et si vous vous demandez comment c'est possible de fusionner un cache et une mémoire RAM, voici comment le tout est implémenté. L'implémentation consiste à couper le cache en deux circuits, dont l'un est un local store, et l'autre transforme le local store en cache. Ce genre de cache séparé en deux mémoires est appelé un phased cache, pour ceux qui veulent en savoir plus, et ce genre de cache est parfois utilisés sur les processeurs modernes, dans des processeurs dédiés à l'embarqué ou pour certaines applications spécifiques.
Le premier circuit vérifie la présence des données à lire/écrire dans le cache. Lors d'un accès mémoire, il reçoit l'adresse mémoire à lire, et détermine si une copie de la donnée associée est dans le cache ou non. Pour cela, il utilise un système de tags qu'on ne détaillera pas ici, mais qui donne son nom à l'unité de vérification : l'unité de tag. Son implémentation est très variable suivant le cache considéré, mais une simple mémoire RAM suffit généralement.
En plus de l'unité de tags, il y a une mémoire qui stocke les données, la mémoire cache proprement dite. Par simplicité, cette mémoire est une simple mémoire RAM adressable avec des adresses mémoires des plus normales, chaque ligne de cache correspondant à une adresse. La mémoire RAM de données en question n'est autre que le local store. En clair, le cache s'obtient en combinant un local store avec un circuit qui s'occupe de vérifier de vérifier les succès ou défaut de cache, et qui éventuellement identifie la position de la donnée dans le cache.

Pour que le tout puisse servir alternativement de local store ou de cache, on doit contourner ou non l'unité de tags. Lors d'un accès au cache, on envoie l'adresse à lire/écrire à l'unité de tags. Lors d'un accès au local store, on envoie l'adresse directement sur la mémoire RAM de données, sans intervention de l'unité de tags. Le contournement est d'autant plus simple que les adresses pour le local store sont distinctes des adresses de la mémoire vidéo, les espaces d'adressage ne sont pas les mêmes, les instructions utilisées pour lire/écrire dans ces deux mémoires sont aussi potentiellement différentes.

Il faut préciser que cette organisation en phased cache est assez naturelle. Les caches de texture utilisent cette organisation pour diverses raisons. Vu que cache L1 et cache de texture sont le même cache, il est naturel que les caches L1 et autres aient suivi le mouvement en conservant la même organisation. La transformation du cache L1 en hydride cache/local store était donc assez simple à implémenter et s'est donc faite facilement.
La cohérence des caches sur un GPU
[modifier | modifier le wikicode]Pour terminer ce chapitre, nous allons parler de la cohérence des caches. La cohérence des caches est un problème qui se manifeste à plusieurs niveaux, quand on parle d'un GPU : entre CPU et GPU, ou entre processeurs de shaders. Nous allons voir les deux cas l'un après l'autre.
La cohérence des caches pour les transferts DMA
[modifier | modifier le wikicode]Supposons que le CPU ait transféré les données dans la mémoire vidéo, avec un transfert DMA. Une situation bien précise pose problème : quand un transfert DMA écrase des données devenues inutiles, pour les remplacer par des données utiles. C'est très fréquent, les pilotes graphiques libèrent souvent de la mémoire vidéo pour la réallouer immédiatement après, afin de ne pas gaspiller de VRAM. Sans intervention du GPU, le remplacement des données aura été fait en mémoire vidéo, pas dans les caches du GPU. Et tout accès ultérieur au cache renverra la donnée écrasée.

Pour éviter cela, le GPU invalide ses caches en cas de transfert DMA. Par invalider, on veut dire que le cache est réinitialisé, mis à zéro, il est rendu vierge de toute donnée. Ainsi, tout accès mémoire ultérieur se fera en mémoire RAM sans passer par le cache. Les données lues depuis la RAM seront ensuite copiées dans le cache, mais ce seront les données valides écrites après le transfert DMA. Le contenu du cache est alors reconstitué au fur et à mesure des accès mémoire. L'invalidation est automatique sur les anciens GPU, elle est réalisée par le processeur de commande. Sur les GPU modernes, elle est réalisée par le programmeur, comme on va le voir dans la section immédiatement suivante.
La cohérence des caches entre CPU et GPU
[modifier | modifier le wikicode]Dans ce qui suit, on suppose qu'il n'y a qu'une seule mémoire RAM, qui est partagée entre CPU et GPU, et sert à la fois de RAM et de mémoire vidéo. Il s'agit de ce que l'on appelle la mémoire unifiée. Elle est utilisée dans de nombreuses consoles de jeu, mais aussi avec les GPU intégrés, qui sont dans le processeur. La mémoire unifiée n'implique pas de transfert DMA entre CPU et GPU, vu qu'il n'y a qu'une seule RAM. Par contre, un problème différent du précédent peut survenir.
Le processeur n'écrit pas directement en mémoire RAM, mais dans son cache. Les écritures sont propagées en RAM avec un certain retard, quand les données sont évincées du cache pour faire de la place à de nouvelles données. Et même quand elles sont propagées en RAM, les écritures ne sont pas propagées dans les caches du GPU. Pour corriger cela, lorsque le processeur envoie des données au GPU, il force le GPU à invalider ses caches. Il envoie une commande dédiée pour, qui précède les commandes liées au rendu 2D/3D/autres.

Au niveau du processeur, le processeur doit écrire les données dans la mémoire RAM, avant de faire le transfert DMA. Mais la présence de caches pose problème : les écritures peuvent être interceptées par le cache et ne pas être propagées en RAM. Pour éviter cela, les processeurs modernes marquent des blocs de mémoire comme "non-cacheables", à savoir que toute lecture/écriture dedans se fait sans passer par le cache. C'est une fonctionnalité très importante pour communiquer avec les périphériques. Pour les GPU dédiés/soudés, cela a un lien avec la mémoire vidéo mappée en mémoire. Plus haut, nous avions dit que la mémoire vidéo est visible dans l'espace d'adressage du processeur, à savoir qu'un bloc de mémoire est détourné pour adresser non pas la RAM, mais la mémoire vidéo. Et bien ce bloc de mémoire entier est marqué comme étant non-cacheable.

La situation peut être optimisée sur les GPU intégrés. Si le GPU est conçu pour, il n'y a pas besoin de marquer les données comme non-cacheables. Le cas le plus simple est celui où le CPU et le GPU partagent leur cache L3/L4. Dans ce cas, il n'y a qu'un seul cache L3/L4 qui ne contient qu'une seule copie valide, écrite par le CPU et lue par le GPU. Il faut juste garantir que la donnée soit lue par le GPU depuis le L3, mais c'est là une question d'inclusivité du cache, qui ne nous concerne pas ici. Si le CPU et le GPU ne partagent pas de cache, il suffit que le GPU puisse lire les caches du CPU. C'est la méthode utilisée sur l'APU Trinity d'AMD.

La cohérence des caches entre processeurs de shaders
[modifier | modifier le wikicode]Pour terminer, il faut voir la cohérence des caches entre processeurs de shaders. Une carte graphique moderne est, pour simplifier, un gros processeur multicœurs auquel on aurait rajouté des ROPs, les circuits de la rastérisation et les unités de textures. Il n'est donc pas étonnant que les problèmes rencontrés sur les processeurs multicœurs soient aussi présents sur les GPU, la cohérence des caches ne fait pas exception.
Pour simplifier les explications, nous allons partir du principe que chaque processeur de shaders a son propre cache de données. Prenons deux processeur de shaders qui ont chacun une copie d'une donnée dans leur cache. Si un processeur de shaders modifie sa copie de la donnée, l'autre ne sera pas mise à jour. L'autre processeur manipule donc une donnée périmée : il n'y a pas cohérence des caches.

La réalité est cependant plus complexe, dans le sens où il n'y a souvent pas un cache par processeur de shaders, mais une hiérarchie de cache assez complexe, avec un cache L1 par processeur de shaders, un cache L2 partagé entre plusieurs processeur de shaders, des caches partagés entre tous les processeur de shaders, etc. Certains GPU partagent leur cache L1 d’instructions entre plusieurs processeur de shaders, d'autres non. Mais le principe reste valide, tant qu'un cache n'est pas partagé entre tous les processeurs de shaders : un cache peut contenir une donnée invalide, à savoir qu'elle a été modifiée dans le cache d'un autre processeur de shaders.
Pour corriger ce problème, les ingénieurs ont inventé des protocoles de cohérence des caches pour détecter les données périmées et les mettre à jour. Mais autant ces techniques sont faisables sur des CPU avec un nombre limité de cœurs, autant elles sont impraticables avec un GPU contenant une centaine de cœurs. Heureusement, la cohérence des caches est un problème bien moins important sur les GPU que sur les CPU. En effet, le rendu 3D implique un parallélisme de données : des processeurs/cœurs différents sont censés travailler sur des données différentes. Il est donc rare qu'une donnée soit traitée en parallèle par plusieurs cœurs, et donc qu'elle soit copiée dans plusieurs caches.
En conséquence, les GPU se contentent d'une cohérence des caches assez light, gérée par le programmeur. Si jamais une opération peut mener à un problème de cohérence des caches, le programmeur doit gérer cette situation de lui-même. Pour cela, les GPU supportent des instructions machines spécialisées, qui vident les caches. Par vider les caches, on veut dire que leur contenu est rapatrié en mémoire RAM, et qu'ils sont réinitialisés. Les accès mémoire qui suivront l'invalidation trouveront un cache vide, et devront recharger leurs données depuis la RAM. Ainsi, si une lecture/écriture peut mener à un défaut de cohérence problématique, le programmeur insère une instruction qui invalide le cache, avant l'accès mémoire potentiellement problématique. Ainsi, on garantit que la donnée chargée/écrite est lue depuis la mémoire vidéo et donc qu'il s'agit d'une donnée correcte.
Elle est utilisée pour supporter les techniques de render-to-texture, pour dessiner l'image finale dans une texture (pour y appliquer un filtre de post-traitement, par exemple). Les opérations de render-to-texture étant assez rares, il vaut mieux ne pas rendre les caches de texture accessibles en écriture. L'invalidation du cache au besoin est alors parfaitement adapté. Les autres caches du GPU sont gérés avec le même principe. Pour les caches généralistes, certains GPU modernes commencent à implémenter des méthodes plus élaborées de cohérence des caches.