Outre les points énoncés par Ache, la différence ne se situe pas tellement dans les capacités calculatoires du cœur, mais surtout dans l’architecture et la manière dont est approché le problème.
Le point central du GPU est que tout est orienté pour créer du parallélisme massif. Cela se traduit par des unités plus "simples" que ce que tu peux retrouver sur CPU. Tout plein de mécanismes qui ont été créés spécifiquement dans le but d’accélérer les performances du CPU ont été tout simplement rejetées par design (prefetching, branch prediction, …). Cela permet d’occuper moins de place, d’être moins consommateur en énergie, on peut alors mettre une quantité astronomique d’unités de calculs pour un moindre coût.
L’unité primordiale de calcul sur GPU est le core, il est conçu pour exécuter 32 threads en simultané, une warp, donc pour chaque instruction décodée, elle est appliquée sur chacun des threads (c’est l’idée du SIMD, mais on préfère parler de SIMT dans ce cadre). Cela a deux conséquences:
- Les branchements conditionnels font que tu es obligé d’exécuter chacune des branches à la suite, en évinçant les résultats de tous les threads qui ont suivi l’autre branchement.
- Une instruction est donc appliquée sur 32 données (au maximum) en même temps, et donc d’aller chercher énormément de données en mémoire - je reviendrai sur ce point.
A plus haut niveau, tu as les thread blocks, il s’agit un ensemble de warp exécutés sur un même streaming processor. En approximation, ce sont des warps qui sont schédulés ensemble sur les mêmes core et qui peuvent communiquer par le biais d’une mémoire __local__.
Enfin, l’ensemble des thread blocks forme un kernel qui est exécuté sur ton device et qui partage toute la VRAM de ta carte graphique, qui est __global__.
La relation entre le niveau d’exécution et la mémoire employée est capitale dans le processus GPU. Plus tu essayes d’accéder à de la mémoire "éloignée" et globale, plus ta pénalité est élevée. Les facteurs étaient originellement extrêmement élevés (facilement un facteur 1000 entre les registres et la __global__) - la situation s’est nettement améliorée à ce niveau mais la mémoire registre reste toujours un petit bolide.
Pour palier à cette lenteur, à cette latence qui existe entre la demande d’une donnée et la capacité de pouvoir la traiter, la solution est apportée par le scheduling des warps, la nature même massivement multithreaded fait qu’il existe toujours une warp qu’on peut exécuter en attendant la donnée. Attention que comme tu peux avoir 32 threads qui demandent chacun d’accéder à 32 données différentes et placées à divers endroits en mémoire, cela peut prendre un temps conséquent avant de pouvoir continuer. C’est pour ça qu’on préconise d’accéder à la mémoire par le biais de bank, qui correspondent à des cache lines, des paquets de 128 octets, tu peux accéder à un bank par cycle dans l’idée.
Comme la mémoire et la capacité à fournir l’information suffisamment rapidement est le nerf de la guerre sur GPU, cela se traduit également par deux phénomènes embêtants:
- Tu n’as pas de stack. Le stack consiste (approximativement) en 32 registres de 4 bytes, si tu as besoin de davantage de mémoire pour stocker tes variables locales ou, pire, ton pointer stack pour savoir dans quelle fonction tu es, tu es obligé d’aller emprunter de la mémoire locale qui est nettement plus lente.
- la "synchronisation" des données. Vu le niveau de concurrence proposé sur GPU, il devient très simple de commettre des erreurs de synchronisation et d’accéder à de la mémoire qui est en train d’être employée par d’autres, et ce même au niveau des registres ! Tu dois forcer des fences pour garantir la vision globale des données présentes en mémoire locale ou globale, ce qui est particulièrement lent.