Tiny GPU : un GPU minimal implémenté en Verilog
(github.com/adam-maj)- tiny-gpu est une implémentation minimale de GPU en Verilog destinée à apprendre, depuis les bases, comment un GPU fonctionne au niveau matériel ; elle se concentre sur les principes communs aux GPGPU et aux accélérateurs de ML plutôt que sur du matériel dédié au graphisme
- L’implémentation se compose de moins de 15 fichiers Verilog documentés, de documents sur l’architecture et l’ISA, de kernels d’addition et de multiplication de matrices, ainsi que d’un support pour la simulation de kernels et le traçage d’exécution
- Le GPU exécute un seul kernel à la fois : il charge la mémoire programme et la mémoire de données, configure
thread_count, puis lance le kernel en activant le signalstart - Pour simplifier, chaque cœur traite un block à la fois ; chaque thread possède son ALU, son LSU, son PC et son fichier de registres, mais on suppose que tous les threads convergent vers le même PC après chaque instruction
- La plupart des fonctions des GPU modernes — caches multi-niveaux, mémoire partagée, coalescence mémoire, pipeline, ordonnancement des warps, divergence de branchement, barrières — sont exclues afin de privilégier une structure pédagogique
Le problème que tiny-gpu cherche à résoudre
- Il existe beaucoup de ressources pour apprendre les CPU, de l’architecture jusqu’aux signaux de contrôle, mais les détails techniques bas niveau des GPU modernes restent pour la plupart propriétaires en raison d’un marché très concurrentiel
- Il existe de nombreuses ressources sur la programmation GPU, mais très peu pour comprendre comment un GPU fonctionne au niveau matériel
- Les implémentations open source de GPU comme Miaow et VeriGPU visent l’exhaustivité fonctionnelle et l’exécution, ce qui rend leur structure complexe
- tiny-gpu supprime une grande partie de la complexité d’une carte graphique de production et se concentre sur les éléments essentiels communs aux accélérateurs matériels modernes
- Les composants importants de l’architecture GPU
- La manière dont le modèle de programmation SIMD est implémenté dans le matériel
- La manière dont un GPU gère une bande passante mémoire limitée
Architecture globale
- tiny-gpu est conçu pour n’exécuter qu’un seul kernel à la fois
- La procédure d’exécution d’un kernel est la suivante
- Charger le code du kernel dans la mémoire programme globale
- Charger les données nécessaires dans la mémoire de données
- Indiquer le nombre de threads à exécuter dans le registre de contrôle du périphérique
- Mettre le signal
startà high pour lancer l’exécution du kernel
- Le GPU se compose des unités suivantes
- Registre de contrôle du périphérique
- Dispatcher
- Nombre variable de cœurs de calcul
- Contrôleurs mémoire pour la mémoire de données et la mémoire programme
- Cache
Exécution des kernels et répartition des threads
- Le registre de contrôle du périphérique sert à stocker les métadonnées d’exécution du kernel ; dans tiny-gpu, il ne stocke que
thread_count, le nombre total de threads à exécuter - Le dispatcher répartit les threads entre plusieurs cœurs de calcul lorsque le kernel démarre
- Il regroupe les threads exécutables en parallèle en blocks
- Il envoie les blocks aux cœurs disponibles pour traitement
- Il signale la fin de l’exécution du kernel une fois tous les blocks traités
- Un cœur simplifié traite un seul block à la fois
- Chaque thread possède une ALU, un LSU, un PC et un fichier de registres dédiés
- La gestion de l’exécution des instructions des threads sur ces ressources est l’un des problèmes difficiles des GPU
Structure mémoire et contrôleurs
- Le GPU est conçu pour s’interfacer avec une mémoire globale externe ; pour simplifier, la mémoire de données et la mémoire programme sont séparées
- Spécifications de la mémoire de données
- Adressage sur 8 bits
- 256 lignes au total
- Données sur 8 bits
- Chaque ligne stocke une valeur inférieure à 256
- Spécifications de la mémoire programme
- Adressage sur 8 bits
- 256 lignes au total
- Données sur 16 bits
- Selon l’ISA, chaque instruction occupe 16 bits
- Le contrôleur mémoire suit les requêtes mémoire provenant des cœurs, limite les requêtes en fonction de la bande passante réelle de la mémoire externe et transmet les réponses aux bonnes ressources
- Chaque contrôleur mémoire possède un nombre fixe de canaux selon la bande passante de la mémoire globale
- Le cache est une fonctionnalité en cours de développement : il stocke dans la SRAM du périphérique les données récupérées depuis la mémoire externe, afin de les récupérer plus rapidement lors des requêtes suivantes et de réserver la bande passante mémoire aux nouvelles données
Composition interne des cœurs
- Chaque cœur dispose d’un scheduler unique qui gère l’exécution des threads
- Le scheduler de tiny-gpu exécute jusqu’au bout les instructions d’un block, puis récupère un nouveau block, et exécute les instructions de tous les threads dans un ordre synchronisé
- Des schedulers plus avancés peuvent améliorer l’utilisation des ressources grâce au pipeline et à l’ordonnancement des warps
- La principale contrainte du scheduler vient de la latence lors du chargement et du stockage de données en mémoire globale
- La plupart des instructions peuvent être exécutées de manière synchrone
- Les opérations load-store comme
LDRetSTRsont asynchrones, il faut donc organiser l’exécution des instructions autour de longues attentes
- Le Fetcher récupère de manière asynchrone dans la mémoire programme l’instruction correspondant au compteur de programme courant
- Le Decoder décode l’instruction récupérée en signaux de contrôle pour l’exécution des threads
- Le fichier de registres de chaque thread conserve les données en cours de calcul et permet le motif SIMD
- Les registres en lecture seule contiennent
%blockIdx,%blockDimet%threadIdx - Le kernel peut s’exécuter sur des données différentes selon l’ID local du thread
- Les registres en lecture seule contiennent
- L’ALU de chaque thread traite les instructions arithmétiques
ADD,SUB,MULetDIV CMPindique si le résultat de la différence entre deux registres est négatif, nul ou positif, et stocke le résultat dans le registreNZPde l’unité PC- Le LSU de chaque thread accède à la mémoire de données globale et gère
LDR,STRainsi que la latence mémoire asynchrone - Le PC de chaque thread détermine la prochaine instruction à exécuter
- Par défaut, il augmente de 1 à chaque instruction
BRnzpbranche vers une ligne spécifique de la mémoire programme si la condition du registreNZPdéfinie par leCMPprécédent est satisfaite- Les boucles et les conditions sont implémentées de cette manière
- Pour simplifier, tiny-gpu suppose que tous les threads convergent vers le même PC après chaque instruction
- Dans un vrai GPU, des threads individuels peuvent brancher vers des PC différents ; le groupe de threads traité ensemble se divise alors en plusieurs flux d’exécution, ce qui provoque une divergence de branchement
ISA
- tiny-gpu implémente une ISA de 11 instructions pour exécuter de simples kernels de preuve de concept, comme l’addition et la multiplication de matrices
- Instructions prises en charge
BRnzp: saute vers une autre ligne de la mémoire programme si la conditionNZPest satisfaiteCMP: compare deux valeurs de registre et stocke le résultat dans le registreNZPADD,SUB,MUL,DIV: opérations arithmétiques de base pour les calculs tensorielsLDR: charge des données depuis la mémoire globaleSTR: stocke des données dans la mémoire globaleCONST: charge une valeur constante dans un registreRET: signale la fin d’exécution du thread courant
- Chaque registre est désigné sur 4 bits, pour un total de 16 registres
- Les 13 registres de
R0àR12sont des registres libres en lecture-écriture - Les 3 derniers sont des registres spéciaux en lecture seule qui fournissent
%blockIdx,%blockDimet%threadIdx, nécessaires au SIMD
- Les 13 registres de
Flux d’exécution
- Lorsqu’il exécute une instruction, chaque cœur suit le flux de contrôle suivant
FETCH: récupérer l’instruction suivante du PC courantDECODE: décoder l’instruction en signaux de contrôleREQUEST: siLDRouSTRest nécessaire, demander les données à la mémoire globaleWAIT: si nécessaire, attendre la réponse de la mémoire globaleEXECUTE: effectuer le calcul sur les donnéesUPDATE: mettre à jour le fichier de registres et le registreNZP
- Ce flux de contrôle est conçu pour la simplicité et la compréhension
- Dans une implémentation réelle, certaines étapes peuvent être fusionnées afin d’optimiser le temps de traitement, ou bien un pipeline peut coordonner l’exécution de plusieurs instructions sur les ressources du cœur
- Chaque thread suit le même chemin d’exécution pour calculer sur les données de son fichier de registres dédié
- La structure ressemble à un diagramme de CPU, mais se distingue par la présence de
%blockIdx,%blockDimet%threadIdxdans des registres en lecture seule, ce qui rend le SIMD possible
Exemples de kernels
- Des kernels d’addition et de multiplication de matrices ont été écrits comme preuves de concept de l’ISA
- Les fichiers de test du dépôt peuvent simuler entièrement ces kernels sur le GPU, et générer l’état de la mémoire de données ainsi qu’une trace complète d’exécution
-
Addition de matrices
matadd.asmadditionne deux matrices 1 x 8- Les 8 additions élément par élément sont chacune effectuées dans un thread séparé
- Les registres
%blockIdx,%blockDimet%threadIdxdémontrent la programmation SIMD - Les instructions
LDRetSTRincluent la gestion mémoire asynchrone
-
Multiplication de matrices
matmul.asmmultiplie deux matrices 2 x 2- Il calcule élément par élément le produit scalaire des lignes et colonnes concernées
CMPetBRnzpdémontrent les branchements à l’intérieur d’un thread- Comme tous les branchements reconvergent, cela fonctionne avec l’implémentation actuelle de tiny-gpu
Simulation
- Pour exécuter la simulation d’un kernel, iverilog et cocotb sont nécessaires
- Procédure de préparation
- Installer le compilateur Verilog et cocotb avec
brew install icarus-verilogetpip3 install cocotb - Télécharger la dernière version de sv2v, décompresser l’archive et ajouter le binaire à
$PATH - Exécuter
mkdir buildà la racine du dépôt
- Installer le compilateur Verilog et cocotb avec
- Les simulations de kernels s’exécutent avec
make test_mataddetmake test_matmul - Les résultats d’exécution sont écrits sous forme de fichiers de logs dans
test/logs- État initial de la mémoire de données
- Trace complète d’exécution du kernel
- État final de la mémoire de données
- Au début de chaque fichier de log, on voit les matrices d’entrée ; à la fin, la mémoire de données finale contient la matrice résultat
- La trace d’exécution inclut, à chaque cycle, l’état d’exécution de tous les threads de tous les cœurs
- Instruction courante
- PC
- Valeurs des registres
- Informations d’état
Fonctionnalités GPU avancées volontairement absentes
- Pour simplifier, tiny-gpu exclut la plupart des améliorations de performance et de fonctionnalités des GPU modernes
-
Caches multi-niveaux et mémoire partagée
- Les GPU modernes utilisent plusieurs niveaux de cache afin de réduire les accès à la mémoire globale
- tiny-gpu n’implémente qu’un seul niveau de cache entre la ressource demandeuse et le contrôleur mémoire, pour stocker les données récentes
- Les caches multi-niveaux réduisent le temps de chargement en mettant en cache les données fréquemment utilisées plus près de leur lieu d’utilisation
- Les GPU utilisent aussi parfois de la mémoire partagée afin que les threads d’un même block puissent échanger des résultats intermédiaires
-
Coalescence mémoire
- Plusieurs threads exécutés en parallèle accèdent souvent à des adresses contiguës, par exemple à des éléments adjacents d’une matrice
- La coalescence mémoire analyse les requêtes mémoire en file d’attente et fusionne les requêtes adjacentes en une seule transaction
- L’objectif est de réduire le temps consacré à l’adressage et de traiter les requêtes ensemble
-
Pipeline
- Les cœurs de tiny-gpu ne commencent l’instruction suivante qu’une fois terminée l’exécution d’une instruction pour un groupe de threads
- Les GPU modernes streament l’exécution de plusieurs instructions séquentielles tout en garantissant l’exécution dans l’ordre pour les instructions dépendantes
- Cela améliore l’utilisation des ressources afin d’éviter que les ressources du cœur restent inactives dans des situations comme l’attente de requêtes mémoire asynchrones
-
Ordonnancement des warps
- Un block est divisé en warps, des lots de threads pouvant être exécutés ensemble
- Lorsqu’un warp est en attente, les instructions d’un autre warp sont exécutées, ce qui permet de traiter plusieurs warps simultanément sur un même cœur
- C’est similaire au pipeline, mais appliqué aux instructions de threads différents
-
Divergence de branchement
- tiny-gpu suppose que tous les threads d’un même lot se trouvent au même PC après chaque instruction
- En pratique, selon les données, des threads individuels peuvent brancher vers des lignes différentes
- Les threads ayant des PC différents sont séparés en flux d’exécution distincts, et le moment où ils reconvergent doit également être géré
-
Synchronisation et barrières
- Les GPU modernes peuvent définir des barrières pour faire attendre les groupes de threads d’un même block jusqu’à ce que tous aient atteint un point donné
- C’est utile lorsque les threads doivent échanger des données partagées, afin de garantir que le traitement des données est terminé
Prochaines étapes
- Les améliorations envisagées sont les suivantes
- Ajouter un cache d’instructions simple
- Construire un adaptateur permettant d’utiliser le GPU dans Tiny Tapeout 7
- Ajouter une divergence de branchement de base
- Ajouter une coalescence mémoire de base
- Ajouter un pipeline de base
- Optimiser le flux de contrôle et l’utilisation des registres afin d’améliorer le temps de cycle
- Écrire un kernel graphique de base ou ajouter un matériel graphique simple pour démontrer les capacités graphiques
- Les utilisateurs qui souhaitent améliorer le dépôt peuvent contribuer via des PR
1 commentaires
Commentaires Hacker News
Intel fait figure d’exception en publiant beaucoup de documentation technique sur ses GPU : https://kiwitree.net/~lina/intel-gfx-docs/prm/
On peut aussi trouver en ligne les manuels des i810/815, et à part l’étrange trou avant les 965, où les 855/910/915/945 manquent, la documentation a été assez régulièrement maintenue
Cela inclut même la documentation sur l’architecture du jeu d’instructions pour des produits actuels et anciens, mais cela semble davantage destiné aux implémenteurs qu’à des explications de haut niveau pour passionnés
J’aimerais que toutes les entreprises suivent cette approche
Cela dit, je vois ça davantage comme un coprocesseur SIMD
Pour l’appeler un GPU, je pense qu’il devrait au moins avoir une forme quelconque de sortie d’affichage
Je sais bien que le terme est devenu assez souple récemment, avec Nvidia et d’autres qui vendent aussi comme GPU des variantes d’architectures graphiques réservées aux serveurs, mais dans la conception d’un GPU, la partie graphique représente encore aujourd’hui une part importante de la complexité
Un GPU qui n’affiche rien peut rester utile
À mon travail, il y a environ 75 stations de travail équipées de Quadro milieu de gamme, mais les cartes n’ont que du mini-DisplayPort et l’entreprise n’achète que des câbles HDMI, donc elles sont toutes branchées sur la partie graphique intégrée
Malgré ça, ces cartes accélèrent toujours les logiciels et traitent les graphismes ; elles ne font simplement pas l’affichage à l’écran
Il existe aussi d’autres exemples : https://github.com/jbush001/NyuziProcessor
Quel volume faudrait-il pour faire fabriquer économiquement ce type de processeur chez TSMC ou une autre fonderie ?
J’aimerais me mettre aux FPGA, mais honnêtement il est difficile de savoir par où commencer, et l’ensemble du domaine paraît assez intimidant
Mon objectif final serait de créer une carte d’accélération pour les LLM ; même si c’est un objectif fixé un peu arbitrairement, j’ai l’impression qu’il y aurait beaucoup de recoupements avec ce projet, avec peut-être surtout la partie offload mémoire qui différerait pour charger des modèles plus grands
Débuter avec les FPGA doit être découpé en plusieurs sous-compétences, et il faut aussi ajuster ses attentes
On n’attendrait pas d’un ingénieur logiciel qu’il construise d’emblée un ordinateur complet à partir des principes de base, écrive une architecture de jeu d’instructions, comprenne le code machine, le transforme en assembleur, puis développe encore un langage de programmation pour créer des applications en Python
Il faut commencer par le haut et descendre dans la pile
Si on abstrait la complexité et qu’on se concentre sur la construction de systèmes avec des IP déjà prêtes, la conception FPGA est en réalité assez accessible
Je recommande souvent quelque chose comme MATLAB, car avec une DevKit disposant d’un design de référence, on peut créer une première application avec HDL Coder
Sinon, on se retrouve avec l’énorme charge d’apprendre l’architecture du calcul numérique, Verilog, le timing, les transceivers/entrées-sorties, le pin planning, Quartus/Vivado, la simulation/vérification, les systèmes embarqués, etc.
En résumé, il faut commencer par la conception au niveau système, apprendre à prendre des IP plug-and-play et à les assembler au niveau supérieur, puis insérer ces modules dans un design de référence déjà prêt
Ensuite, on peut enlever progressivement les couches pour faire apparaître la complexité sous-jacente
On peut faire les exercices en parallèle de la lecture de Harris&Harris
Une fois les exercices et le livre terminés, ce sera le moment de démarrer son propre projet
À noter qu’il y a aussi des rencontres hebdomadaires chez HackerMojo, auxquelles on peut participer via Zoom même sans être dans la Valley
C’est par là qu’il faut commencer
Le matériel est en fait la partie facile, et aussi la partie difficile du point de vue de la fabrication
alwaysséquentiel ?Il y avait un site appelé opencores qui rassemblait plusieurs projets HDL open source
Je me demande s’il existe aujourd’hui de bons simulateurs HDL distribués à grande échelle, de niveau HPC
Utiliser des GPU modernes pour la simulation au niveau RTL semble pertinent
Ce n’est pas le même site, ou bien un autre endroit similaire ?
Sur des CUDA cores modernes, est-ce courant d’avoir une division comme véritable instruction, ou bien est-ce généralement émulé en logiciel ?
Un vrai circuit matériel de division prend énormément de place, donc je ne m’attendais pas à le voir dans l’ALU d’un GPU
En Verilog, écrire une seule ligne comme
DIV: begin alu_out_reg <= rs / rt; endest trop facile, mais cette seule ligne consomme beaucoup de siliciumSi on se contente de simuler le Verilog, on risque de ne pas s’en rendre compte
Le projet s’arrête à la simulation, et il faudrait beaucoup plus de travail pour en faire un vrai matériel
Personnellement, je pense qu’il faudrait appeler ça autrement
L’écart entre les deux se réduit et chacun ajoute des fonctions de l’autre, mais il reste encore une différence importante
À mon avis, cela a à voir avec la loi d’Amdahl [0]
Dans ce sens, on peut appeler le CPU un processeur optimisé pour la latence, et le GPU un processeur optimisé pour le débit
Plus précisément, on pourrait aussi appeler [1] le CPU un processeur à dépendances de données longues et profondes, et le GPU un processeur à dépendances de données larges et plates
[0]: https://en.wikipedia.org/wiki/Amdahl%27s_law
[1]: https://en.wikipedia.org/wiki/Data_dependency
Un tenseur n’est qu’un tableau à n dimensions
On peut ajouter du logiciel ou du firmware par-dessus pour le faire fonctionner comme un GPU
J’ai essayé de reconstituer les morceaux à partir du code source d’EDK2, mais il n’est pas clair dans quelle mesure cela est spécifique à QEMU
tiny-gpuselon laquelle tous les threads « convergent » vers le même compteur ordinal après chaque instruction est une simplification beaucoup trop naïveSur un vrai GPU, des threads individuels peuvent bifurquer vers des PC différents, et il se produit une divergence de branchement où un groupe de threads d’abord traité ensemble se sépare en exécutions distinctes
Il aurait sans doute mieux valu commencer par faire de la programmation GPU avant de fabriquer un GPU en silicium
Et en plus, ça ne correspond pas vraiment à du SIMD
C’est la même personne qui avait déjà dit avoir fabriqué un CPU en assemblant les circuits d’autres gens pour faire clignoter une LED
__syncthreads()à chaque exécution ?