Les cartes graphiques/La hiérarchie mémoire d'un GPU

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. La hiérarchie mémoire des GPUs est assez particulière, que ce soit au niveau des caches ou de la mémoire, parfois des registres. Un GPU contient évidemment une mémoire vidéo, de grande taille, capable de stocker textures, vertices, images et bien d'autres choses nécessaires pour un rendu 3D. On y trouve souvent des mémoires caches dédiées aux textures ou aux vertices, et les GPUs récents contiennent aussi des caches tout court. Mais sur les cartes graphiques récentes, les caches sont complétés par des Local Store, des mémoires RAM qui servent de cache, mais fonctionnent comme des mémoires RAM normales.

La mémoire vidéo modifier

La mémoire vidéo d'une carte graphique dédiée est nécessaire pour stocker l'image à afficher à l'écran, mais aussi pour mémoriser temporairement des informations importantes. Dans le cas le plus simple, elle sert simplement de Framebuffer : elle stocke l'image à afficher à l'écran. Au fil du temps, elle s'est vu ajouter d'autres fonctions, comme stocker les textures et les sommets de l'image à calculer, ainsi que divers résultats temporaires.

Les spécificités des RAM des cartes vidéo dédiées modifier

Sur les cartes graphiques dédiées, la mémoire vidéo est très proche des mémoires RAM qu'on trouve sous forme de barrettes dans nos PC, à quelques différences près. Le point le plus important est que la mémoire vidéo d'une carte dédiée n'est pas présente sous la forme de barrettes de mémoire. À la place, les puces de mémoire sont soudées sur la puce. La conséquence est que l'on ne peut pas upgrader la RAM d'une carte vidéo. Ce serait sympathique, mais ne serait pas d'une grande utilité, car les jeux vidéos gourmands en mémoire vidéo sont aussi gourmands en puissance de calcul. Upgrader la RAM d'une carte graphique ne sert à rien si celle-ci n'a pas assez de puissance pour jouer à des jeux récents avec un framerate convenable.

Le fait que la mémoire est soudée simplifie la conception de la carte graphique, mais cela a des avantages au niveau électrique, qui permettent d'améliorer les performances. Niveau performances, la mémoire vidéo a des performances radicalement différentes de la RAM des PC. Elle a un temps d'accès très long, de plusieurs centaines de cycles d'horloge. Cela a des conséquences sur l'architecture de la carte graphique, notamment au niveau des processeurs de shaders, qui sont conçus pour gérer ces temps d'accès long, comme on l'a vu dans le précédent chapitre. Par contre, elle a un très grand débit, autrement dit une bande passante élevée, proche de la centaine de gigaoctets par secondes sur les cartes graphiques modernes. Pour rappel, la bande passante d'une mémoire dépend de deux paramètres : sa fréquence et la largueur de son bus mémoire. Détaillons le dernier, qui explique en grande partie pourquoi la mémoire vidéo a un débit supérieur à la mémoire système.

Le bus mémoire et sa largeur modifier

Le bus mémoire est ce qui connecte la mémoire au reste de la carte graphique. La largueur de ce bus n'est autre que la quantité de données que celui-ci peut transmettre à chaque cycle d'horloge, le nombre de bits que l'on peut lire/écrire en un cycle d'horloge. Sur la RAM système, le bus est de 64 bits sur les mémoires DDR modernes, mais peut monter à 128 bits en utilisant des techniques comme le dual channel, voire en 192/256 bits avec des techniques de triple/quad channel qui sont rarement utilisées. Globalement, la configuration classique sur un PC moderne est 128 bits, avec quelques machines bas de gamme en 64 bits. Sur les cartes graphiques modernes, les bus de 128 bits ou moins sont utilisés sur les cartes graphiques de faible performance, le reste ayant un bus mémoire de 192, 256, 384, voire 512 bits. En clair, elles permettent de lire/écrire plus de données par cycle d'horloge qu'une RAM système, de 2 à 8 fois plus.

Le fait que le bus est plus large est lié au fait que les puces mémoires sont soudées. La mémoire vidéo des cartes dédiées est composée de pleins de puces mémoires accessibles en parallèle, ce qui permet de charger des blocs de plusieurs centaines d'octets en une seule fois. Les barrettes de mémoire ont des limites au nombres de broches que leur connecteur peut accepter, qui est proche de 300 pour les DDR actuelles (beaucoup de ces broches ne transfèrent pas des données, ce qui fait qu'on a bien 64 broches dédiées aux données seulement). Sans connecteurs, on est limité à ce que la puce du GPU peut accepter, et on est alors entre 4000 à 6000 broches sur les sockets de CPU ou de GPU actuels.

 
Puces mémoires d'un GPU et d'une barrette de mémoire.

Pour résumer, sur les cartes graphiques dédiées, la RAM vidéo a un débit proche de la centaine de gigaoctets par secondes. Avec une mémoire RAM unifiée, vous pouvez facilement diviser cette estimation par 10.

Les caches d'un GPU modifier

Les cartes graphiques sont censées avoir peu de caches. Les caches en question sont des caches spécialisés pour les textures ou pour les sommets, ce qui leur vaut les noms de caches de texture et de cache de sommets. Il n'existe pas beaucoup d'autres caches sur les anciennes cartes graphiques, l'usage de caches plus complexes n'étant pas vraiment utile sur les cartes graphiques. Ce n'est que par la suite, quand les GPU commencèrent à être utilisés pour du calcul généraliste (scientifique, notamment), que la situation changea. Les GPU utilisèrent alors de plus en plus de caches généralistes.

Le cache de textures modifier

Le cache de textures, comme son nom l'indique, est un cache spécialisé dans les textures. Pour améliorer les performances, les cartes graphiques actuelles disposent généralement de plusieurs caches de textures. Déjà, toutes les cartes graphiques modernes disposent de plusieurs unités de texture, mais elles ne se partagent pas un seul cache. Faire ainsi compliquerait beaucoup trop les GPU et n'aurait aucun intérêt. A la place, chaque unité de texture dispose de son ou ses propres caches de textures.

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. A 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 généralistes modifier

La hiérarchie mémoire des GPU modernes ressemble de plus en plus à celle des CPU, avec toute une hiérarchie de caches, avec des caches L1, L2, L3, etc. Pour rappel, les processeurs multicœurs modernes ont pleins de mémoires cache, avec au minimum deux niveaux de cache, le plus souvent trois. Les trois niveaux de cache sont appelés les caches L1, L2 et L3. Pour le premier niveau, on trouve deux caches spécialisés 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 par cœur/processeur, qui peut stocker indifféremment données et instructions. Le cache L3 est un cache partagé entre tous les cœurs/processeurs. Les GPU ont une organisation similaire, sauf que le nombre de cœurs est beaucoup plus grand que sur un processeur moderne.

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, alors que les programmes généralistes effectuent un grand nombre d'instructions sur une quantité limitée de données. La première caractéristique est que les shaders sont des programmes assez légers, qui ont peu d'instructions. Les caches d'instructions L1 sont généralement assez petits, plus que pour les CPU, généralement quelques dizaines ou centaines de kiloctets. Et même malgré cela, il n'est pas rare qu'un shader tienne tout entier dans le cache d'instruction, alors qu'une telle situation serait impensable sur un processeur généraliste. 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.

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. Il faut dire que les processeurs de shaders ont beaucoup de registres, et que ceux-ci peuvent contenir un grand nombre de données. Aussi, 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. Mais le fait est qu'un shader s’exécute en un grand nombre d'instances, chacune traitant un paquet de données différent. Il est très fréquent que différentes instances s’exécutent chacune sur un même cœur et ces différentes instances tendent à accéder à des données très proches, voire à des données identiques. Si un shader charge une donnée dans le cache, la donnée et ses voisines sont alors disponibles pour les autres instances. Le cas le plus classique est celui de l'accès aux textures : lors du placage de textures, des pixels contiguës accèderont à des texels contiguës. Et outre les textures, les pixels shaders ont tendance à traiter des pixels proches, donc à avoir besoin de données proches en mémoire. Ce qui fait que les caches de données sont quand même utiles.

Dans le même registre, un shader a besoin de certaines informations spécifiques, généralement constantes, pour faire son travail. Toutes les instances du shader manipulent ces données, elles ont besoin de les lire, pour les utiliser lors de l’exécution, les copier dans des registres, etc. Les GPU incorporent des caches de constantes pour accélérer l'accès à ces données. Ainsi, quand un shader lit une donnée, elle est chargée dans le cache de constante, ce qui fait que les futures instances auront accès à celle-ci dans le cache. Ces caches de constante sont séparés des autres caches de données pour une raison bien précise : les constantes en question sont d'accès peu fréquent. Généralement, on a un accès au début de chaque instance de shader, guère plus. Vu que ce sont des données peu fréquemment utilisées, elles sont censée ê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é, on n'a pas ce problème. 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.

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. En théorie, chaque unité de shader devrait incorporer un cache de sommets et un cache de textures. Mais le processeur de shader exécute soit un pixel shader, soit un vertex shader, mais pas les deux en même temps. Donc, autant utiliser un seul cache, qui sert alternativement de cache de vertex et de cache de texture, afin d'économiser des circuits. Une fois les deux fusionnés, on obtient un cache de donnée généraliste, capable de traiter sommets et pixels, voire d'autres données. La seule difficulté tient au filtrage de textures et à sa décompression, mais cela demande juste de router les données lues vers l'unité de texture ou directement vers les registres/unités de calcul, ce qui est facile à faire.

La cohérence des caches sur un GPU modifier

Une carte graphique moderne est, pour simplifier, un gros processeur multicœurs. Dans les grandes lignes, si on omet les circuits spécialisés pour le rendu 3D comme les circuits de la rastérisation ou les unité de textures, c'est une définition parfaite. La différence est que les GPU ont un très grand nombre de cœurs, bien supérieur aux misérables 4 à 16 cœurs d'un CPU. Par contre, cela signifie que les problèmes rencontrés sur les processeurs multicœurs sont aussi présents sur les GPU. Le principal est ce qu'on appelle la cohérence des caches. Pour comprendre à quoi cela fait référence, il faut faire un rappel sur les caches d'un processeur multicœurs.

Un processeur multicœurs dispose de beaucoup de caches. Certains sont des caches dédiés, c'est à dire qu'ils sont reliés à un seul cœur, pas aux autres. D'autres sont des caches partagés entre plusieurs cœurs, voire entre tous les cœurs de la puce. Les GPU contiennent les deux, comme les CPU multicœurs, avec cependant des différences. La règle, valable pour les CPU et GPU, est que les caches de plus haut niveau (L1, parfois L2) sont dédiés à un cœur, alors que les caches de plus bas niveau (L2, L3) sont partagés entre plusieurs cœurs, voire tous. Typiquement, on a des caches L1 dédiés, des caches L2 partagés entre plusieurs cœurs, et un cache L3 partagé entre tous les cœurs. Mais cette règle est parfois violée, notamment sur les GPU. Sur les CPU multicœurs, les caches L1, de données et d'instruction, sont dédiés à un cœur. Et autant c'est le cas sur la plupart des GPU, ont a vu plus haut que certains GPU partagent leur cache L1 d’instructions.

Le fait que certains caches soient dédiés ou partagés entre un nombre limité de cœurs entraine des problèmes. Prenons deux processeurs qui ont chacun une copie d'une donnée dans leur cache. Si un processeur 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. 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. Il existe beaucoup de protocoles de cohérence des caches et la plupart utilisent des techniques dites d'espionnage du bus où chaque cache étudie ce qui se passe sur le bus mémoire. Mais autant ces techniques sont faisables avec un nombre limité de cœurs, autant elles sont impraticables avec une centaine de coeurs. Les GPU doivent donc limiter la cohérence des caches à un niveau praticable.

 
Cohérence des caches

En pratique, les caches d'un GPU sont gardés incohérents et aucun protocole de cache de cache n'est utilisé. Et ce n'est pas un problème, car le rendu 3D implique un parallélisme de donnée : 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. La cohérence des caches est donc un problème bien moins important sur les GPU que sur les CPU. 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é. 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 doit insérer une instruction pour invalider le cache dans son programme avant de faire 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, donc qu'il s'agit d'une donnée correcte. Nous avons vu plus haut que c'est cette technique qui est utilisée pour les caches de textures. Ce cas est assez particulier car les textures sont censées être accédée en lecture uniquement, sauf dans de rares cas de techniques de render-to-texture. Aussi, ce modèle d'invalidation du cache au besoin est parfaitement adapté. Les autres caches spécialisés fonctionnent sur le même principe. Même chose pour les caches généralistes, bien que certains GPU modernes commencent à implémenter des méthodes plus élaborées de cohérence des caches.

Les local stores modifier

En plus d'utiliser des caches, les processeurs de la carte graphique utilisent des local stores, des mémoires RAM intermédiaires entre la RAM principale et les caches/registres. Typiquement, chaque processeur de flux possède sa propre mémoire locale. Ces local stores peuvent être vus comme des caches, mais que le programmeur doit gérer manuellement.

 
Local stores d'un GPU.

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é hors du rendu 3D, pour les applications de type GPGPU, où le GPU est utilisé comme architecture multicœurs pour du calcul scientifique.

Les cartes graphiques très récentes fusionnent les local stores 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.