\section{Pourquoi ?}
S'il fallait en réduire les raisons à une seule, c'est vraisemblablement la concurrence commerciale et la croissance du marché des jeux vidéos qui a poussé les fabricants de cartes graphiques à une innovation permanente qui a donnée naissance aux GPUs.
-Le rendu graphique dans ce cadre est une opération exigeante en calcul mais intrinsèquement parallèle car chaque pixel est traité individuellement. L'amélioration des résolutions à aussi contribuer à faire évoluer la nature de ces besoins de \textit{parallèle} à \textit{massivement parallèle}, un écran actuel pouvant comporter plus de 2,5 millions de pixels.
+Le rendu graphique dans ce cadre est une opération exigeante en calcul mais intrinsèquement parallèle car chaque pixel y est traité individuellement. L'amélioration des résolutions a aussi contribué à faire évoluer la nature de ces besoins de \textit{parallèle} à \textit{massivement parallèle}, un écran actuel pouvant comporter plus de 2,5 millions de pixels.
-La technologie de fabrication étant identique à celle de CPUs, c'est donc au niveau de la répartitions des fonctionnalités que les GPUs se distinguent : là où un CPU comporte quelques c\oe urs de calcul et beaucoup de transistors dédiés à la réalisation de mémoire cache et de contrôle de flux, un GPU présente plusieurs unités comportant chacun une grande quantité de c\oe urs de calcul ne disposant que de très peu de mémoire cache et de rudimentaires capacités de contrôle, comme l'illustrent les schémas de la figure \ref{fig-gpucpu1}.
+La technologie de fabrication des GPUs étant identique à celle des CPUs, c'est donc au niveau de la répartition des fonctionnalités que les GPUs se distinguent : là où un CPU comporte quelques c\oe urs de calcul et beaucoup de transistors dédiés à la réalisation de mémoire cache et de contrôle de flux, un GPU présente plusieurs unités comportant chacune une grande quantité de c\oe urs de calcul ne disposant que de très peu de mémoire cache et des capacités de contrôle rudimentaires, comme l'illustrent les schémas de la figure \ref{fig-gpucpu1}.
\begin{figure}[h]
\centering
\includegraphics[width=10cm]{Chapters/chapter1b/img/gpucpu1.png}
- \caption{Comparaison des structures d'un c\oe ur de GPU et d'un c\oe ur de CPU (d'après \cite{CUDAPG}).}
+ \caption{Comparaison des structures d'un c\oe ur de GPU et d'un c\oe ur de CPU (d'après \cite{CUDAPG}). ALU = Arithmetical \& Logical Unit.}
\label{fig-gpucpu1}
\end{figure}
-Cette spécialisation des circuit GPUs a permis d'en améliorer les performances brutes beaucoup plus rapidement que pour les CPUs, au fil des évolutions de la technologie. Il en est allé de même pour les débits mémoire théoriques. Les graphiques de la figure \ref{fig-gpucpu2} comparent les rythmes de ces évolutions pour les GPU Nvidia\textregistered et pour les CPU Intel\textregistered.
+Cette spécialisation des circuits GPU a permis d'en améliorer les performances brutes beaucoup plus rapidement que pour les CPUs, au fil des évolutions de la technologie. Il en est allé de même pour les débits mémoire théoriques. Les graphiques de la figure \ref{fig-gpucpu2} comparent les rythmes de ces évolutions pour les GPUs Nvidia\textregistered~ et pour les CPUs Intel\textregistered.
-Les problèmes recquérant les capacités de calcul spécifiques des GPU ne sont cependant pas limités aux questions de rendu graphique, aussi les scientifiques ont-ils très vite cherché à tirer parti de la puissance de calcul croissante des GPUs pour traiter d'autres types de problèmes, faisant sens à l'acronyme GPGPU.
+Les problèmes requérant les capacités de calcul spécifiques des GPUs ne sont cependant pas limités aux questions de rendu graphique, aussi les scientifiques ont-ils très vite cherché à tirer parti de la puissance de calcul croissante des GPUs pour traiter d'autres types de problèmes, faisant sens à l'acronyme GPGPU (General Purpose Graphical Processing Unit).
\begin{figure}[h]
\centering
- \subfigure[Nombre maximum théorique d'opérations en virgule flottante par seconde en fonction de l'année et de l'architecture.]{\includegraphics[height=5cm]{Chapters/chapter1b/img/gpucpu2a.png}}\quad
- \subfigure[Bande passante théorique maximale des diverses architectures.]{\includegraphics[height=5cm]{Chapters/chapter1b/img/gpucpu2b.png}}
+ \subfigure[Nombre maximum théorique d'opérations en virgule flottante par seconde en fonction de l'année et de l'architecture.]{\includegraphics[width=9cm]{Chapters/chapter1b/img/gpucpu2a.png}}\\
+ \subfigure[Bande passante théorique maximale des diverses architectures.]{\includegraphics[width=9cm]{Chapters/chapter1b/img/gpucpu2b.png}}
\caption{Comparaison des performances des GPUs Nvidia et des CPU Intel (d'après \cite{CUDAPG}).}
\label{fig-gpucpu2}
\end{figure}
-Malgré des caractéristiques prometteuses, les GPGPUs n'ont pas immédiatement declenché une vague d'expérimentations scientifiques dans des domaines variés, l'essentiel des travaux étant liés à la construction et la visualisation des données issues de l'instrumentation d'imagerie médicale.
+Malgré des caractéristiques prometteuses, les GPGPUs n'ont pas immédiatement déclenché une vague d'expérimentations scientifiques dans des domaines variés, l'essentiel des travaux étant liés à la construction et la visualisation des données issues des instruments d'imagerie médicale.
-C'est la parution de l'extension de langage CUDA qui a réellement démocratisé l'emploi des GPGPUs et favorisé l'émergence de travaux variés. CUDA est une extension de haut niveau du langage C permetant d'écrire facilement des fonctions s'exécutant en parallèle sur le GPU, que l'on appelle des \textit{kernels}. L'extension CUDA permet également de gérer de manière transparente les changements d'échelle du parallélisme d'une architecture à une autre ou tout simplement les dimensions de la grille de calcul. Ces possibiltés rendent l'accès facile à la programmation GPU.
+C'est la parution de l'extension de langage CUDA qui a réellement démocratisé l'emploi des GPGPUs et favorisé l'émergence de travaux variés. CUDA est une extension de haut niveau du langage C permettant d'écrire facilement des fonctions s'exécutant en parallèle sur le GPU, que Nvidia nomme \textit{kernels}. L'extension CUDA permet également de gérer de manière transparente les changements d'échelle du parallélisme d'une architecture à une autre ou tout simplement les dimensions de la grille de calcul.
\section{Comment ?}
\subsection{Le matériel}
Pour bien tirer parti des capacités des GPUs, il est important d'en comprendre l'organisation matérielle. Nous limiterons cette présentation à l'architecture dite \textit{Fermi} à laquelle appartient le GPU C2070 qui a servi à l'essentiel de nos expérimentations et dont la constitution est détaillée par les diagrammes de la figure \ref{fig-c2070}.
-Le circuit se divise en 4 groupes de processeurs de flux (les SMs), chacun groupe en comprenant 3 ou 4 pour un total de 14 SMs.
-Chaque SM héberge à son tour 32 c\oe urs de calcul (les SPs).
-Les threads sont exécutés par paquets de 2$\times$16, soit 32 (les warps) sur les 32 c\oe urs de calcul.
+Le circuit se divise en 4 groupes de processeurs de flux (les SMs = \textit{Streaming Multiprocessors}), chaque groupe en comprenant 3 ou 4 pour un total de 14.
+Chaque SM héberge à son tour 32 c\oe urs de calcul (les SPs = \textit{Streaming Processors}).
+Les threads sont exécutés par \textit{warps} de 2$\times$16, soit un par c\oe ur de calcul.
\begin{figure}[h]
\centering
- \subfigure[Organisation en groupes de SMs ]{\includegraphics[height=5cm]{Chapters/chapter1b/img/fermi.png}}\quad
- \subfigure[Constitution d'un SM.]{\includegraphics[height=5cm]{Chapters/chapter1b/img/fermi-sm.png}}
- \caption{Organisation des GPU d'architecture Fermi, comme le C2070 (d'après www.hpcresearch.nl).}
+ \subfigure[Organisation en groupes de SMs ]{\includegraphics[height=6cm]{Chapters/chapter1b/img/fermi.png}}\quad
+ \subfigure[Constitution d'un SM.]{\includegraphics[height=6cm]{Chapters/chapter1b/img/fermi-sm.png}}
+ \caption{Organisation des GPUs d'architecture Fermi, comme le C2070 (d'après www.hpcresearch.nl).}
\label{fig-c2070}
\end{figure}
-Les ressources mémoire sont de plusieurs nature tant du point de vue volume disponible, que portée ou bien débit. Le tableau \ref{tab-gpu-memoire} en synthétise les caractéristiques. Il faut retenir que les registres sont peu nombreux, embarqués sur le circuit (on-chip) et présentent les meilleures performances alors que la mémoire principale, dite globale, est externe au circuit (off-chip), de grande capacité mais présente des latences importantes de plusieurs centaines de cycles d'horloge.
+Les ressources mémoire sont de nature diverse, tant du point de vue du volume disponible, que de la portée ou du débit. Retenons que les registres, peu nombreux et embarqués sur le circuit (\textit{on-chip}), présentent les meilleures performances alors que la mémoire principale, dite globale, externe au circuit (\textit{off-chip}) a une grande capacité mais présente des latences importantes de plusieurs centaines de cycles d'horloge.
+Le fabricant fournit des indications essentiellement qualitatives concernant les latences d'accès aux mémoires, mais ne fournit pas de chiffres sur les latences réelles en fonction des accès et de la proximité du cache lorsqu'un des 3 niveaux est sollicité. Le tableau \ref{tab-gpu-memoire} présente une synthèse des caractéristiques du modèle C2070, issue d'expérimentations menées par nos soins à l'aide des micro-tests présentés dans \cite{wong2010demystifying}.
Une petite quantité de mémoire on-chip est présente sur chaque SM et permet la communication entre les threads s'exécutant sur ce SM. Cette mémoire est appelée \textit{mémoire partagée} et permet des débits bien supérieurs à la mémoire globale.
Partagée & on-chip &bloc &38 &1300& 48K \\
\midrule
Constante& off-chip &grille&370/46/140 &8000& 64K \\
- Texture & off-chip &grille&500/260/372&\ldots & 6G \\
- Locale & off-chip &thread&550 &\ldots & 512K \\
+ Texture & off-chip &grille&500/260/372&N/C & 6G \\
+ Locale & off-chip &thread&550 &N/C & 512K \\
Globale & off-chip &grille&580/80/350 &144 & 6G \\
\bottomrule
\end{tabular}
- \caption{Caractéristiques des différents types de mémoire disponibles sur le GPU. Pour les mémoires cachées, les latences sont données selon l'accès \textit{sans-cache/L1/L2/}. Les mesures ont été obtenues à l'aide des microprogrammes de test de \cite{wong2010demystifying}.}
+ \caption{Caractéristiques des différents types de mémoire disponibles sur le GPU. Pour les mémoires cachées, les latences sont données selon l'accès \textit{sans-cache/L1/L2} et ont été obtenues à l'aide des microprogrammes de test de \cite{wong2010demystifying}. Les valeurs de débit sont données par le constructeur.}
\label{tab-gpu-memoire}
\end{table}
\subsection{Le logiciel}
Dans le modèle CUDA, chaque \textit{kernel} est exécuté par un certain nombre de threads. Chaque thread possède un identifiant unique, accessible à l'intérieur du kernel, ce qui permet d'en individualiser le traitement.
-L'ensemble des threads est organisé en plusieurs blocs indépendants, puis ces blocs en grille. Pour faciliter la représentation de modéles variés, les blocs de threads ainsi que la grille de blocs peuvent être décrits par des tableaux à une, deux ou trois dimensions.
-Le nombre total de threads appartenant à un même bloc est cependant limité, selon la version de GPU, à 512 ou 1024. Le diagramme de la figure \ref{fig-threads} illustre l'organisation d'une grille de calcul à 2 dimensions et des ses blocs, également à 2 dimensions, situation qui correspond à la majorité des modèles d'exécution de nos \textit{kernels} de traitememnt d'image.
+L'ensemble des threads est organisé en plusieurs blocs indépendants, eux-même rassemblés en une grille. Pour faciliter la représentation de modèles variés, les blocs de threads ainsi que la grille de blocs peuvent être décrits par des tableaux à une, deux ou trois dimensions.
+Le nombre total de threads appartenant à un même bloc est cependant limité, selon la version de GPU, à 512 ou 1024. Le diagramme de la figure \ref{fig-threads} illustre l'organisation d'une grille de calcul à 2 dimensions et de ses blocs, également à 2 dimensions, situation qui correspond à la majorité des modèles d'exécution de nos \textit{kernels} de traitement d'image.
\begin{figure}[h]
\centering
- \includegraphics[height=6cm]{Chapters/chapter1b/img/threads.png}
+ \includegraphics[height=8cm]{Chapters/chapter1b/img/threads.png}
\caption{Représentation d'une grille de calcul en 2D et des blocs de threads, à 2 dimensions, qui la composent.}
\label{fig-threads}
\end{figure}
+Au sein d'un même bloc, les threads peuvent communiquer efficacement entre eux grâce à la mémoire partagée, rapide et présentant une faible latence.
+En revanche, la communication inter-blocs passe nécessairement par la mémoire globale dont l'emploi est pénalisant.
-Au sein d'un même bloc, les threads peuvent communiquer efficacement grâce à la mémoire partagée, rapide et présentant une faible latence.
-La communication inter-blocs passe nécessairement par la mémoire globale dont l'emploi est pénalisant.
+L'emploi de la mémoire partagée n'est cependant pas transparent comme peut l'être un cache de niveau 1 (L1) et les motifs d'accès doivent respecter un certain nombre de conditions pour obtenir les performances attendues. Le non-respect de ces contraintes conduit le plus souvent à des fragments de code dont l'exécution s'avère plus lente que leurs équivalents CPU.
-L'emploi de la mémoire partagée n'est cependant pas transparent comme peut l'être un cache de niveau 1 (L1) et les motifs d'accès doivent respecter un certain nombre de conditions pour en obtenir les performances attendues. Le non respect de ces contraintes conduit le plus souvent à un bout programme dont l'exécution s'avère plus lente que son équivalent CPU.
+\subsection{L'occupancy}
+Pour atteindre les meilleures performances possible, le fabricant Nvidia recommande d'avoir toujours suffisamment de threads dans chaque bloc et de blocs dans la grille et ce, pour masquer les latences des accès aux mémoires, mais aussi celles des instructions arithmétiques.
+Il définit un indice nommé \textit{occupancy}, que l'on pourrait franciser par \textit{charge} qui représente, à chaque instant, le rapport du nombre de threads actifs par SM sur le nombre maximum de threads actifs par SM (1536 sur C2070).
-\subsection{l'Occupancy}
-Pour atteindre les meilleures performances possible, le fabricant Nvidia recommande d'avoir toujours suffisamment de threads dans chaque bloc et de blocs dans la grile et ce, pour masquer les latences des accès aux mémoires, mais aussi celles des instructions arithmétiques.
-Il définit un indice nommé \textit{occupancy}, que l'on pourrait franciser par \textit{charge} qui représente, à chaque instant, le rapport du nombre de blocs de threads actifs par SM sur le nombre maximum de threads actifs par SM (1536 sur C2070).
-
-La valeur de l'\textit{occupancy} peut se trouver limitée par un usage trop intensif des ressources mémoire par les threads (registres, méoire partagée) ou bien par une grille de calcul mal dimensionnée. L'ensemble des paramètres intervenant dans le calcul l'\textit{occupancy} est pris en compte dans une feuille de tableur (l'\textit{occupancy calculator}) fournie par Nvidia pour aider les développeurs à bien employer les ressources des divers modèles de GPU.
+La valeur de l'\textit{occupancy} peut se trouver limitée par un usage trop intensif des ressources mémoire par les threads (registres, mémoire partagée) ou bien par une grille de calcul mal dimensionnée. L'ensemble des paramètres intervenant dans le calcul de l'\textit{occupancy} est pris en compte dans l'\textit{occupancy calculator}, feuille de calcul fournie par le fabricant pour aider les développeurs à bien employer les ressources des divers modèles de GPU.
Les limitations de l'\textit{occupancy} ont pour origine :
\begin{enumerate}
-\item {\bf l'usage des registres}. Si chaque thread utilise le maximum de registres possible ($63+1=64$), le bloc de thread affecté au SM ne peut donc activer simultanément que $32K/64=512$ threads, soit une \textit{occupancy} de $512/1536=0.33$.
-\item {\bf l'usage de la mémoire partagée}. L'architecture Fermi permet de choisir la répartition entre cache L1 et mémoire partgée, soit 16K/48K, soit 48K/16K. En configuration 48K de mémoire partagée, si chaque thread en emploie 48 octets, le GPU ne peut activer que $48K/48=1024$ threads, soit une une \textit{occupancy} de $1024/1536=0.66$.
+\item {\bf l'usage des registres}. Si chaque thread utilise le maximum de $64$ registres possible ($63$ pour l'utilisateur $+1$ pour le processeur), le bloc de threads affecté au SM ne peut donc activer simultanément que $32K/64=512$ threads, soit une \textit{occupancy} de $512/1536=0.33$.
+\item {\bf l'usage de la mémoire partagée}. L'architecture Fermi permet de choisir la répartition entre cache L1 et mémoire partagée, soit 16K/48K, soit 48K/16K. En configuration 48K de mémoire partagée, si chaque thread en emploie 48 octets, le GPU ne peut activer que $48K/48=1024$ threads, soit une \textit{occupancy} de $1024/1536=0.66$.
\item {\bf la taille des blocs}. Un SM ne pouvant activer que 8 blocs simultanément, la taille des blocs limite donc potentiellement l'\textit{occupancy}. Si on exécute un \textit{kernel} sur une grille de calcul dont les blocs ont le minimum de 32 threads, les 8 blocs actifs représenteront alors 256 threads, soit une \textit{occupancy} de $256/1536=0.16$.
\end{enumerate}
-Nous verrons que cette notion d'\textit{occupancy} si elle conserve du sens, peut toutefois être remise en question en optimisant d'autres aspects permettant d'arriver à une réduction de l'effet des latences, comme le parallélisme d'instructions ou l'augmentation du volume des transactions. Ces techniques ainsi que l'emploi fin des mémoires du GPU permettent d'obtenir des performances élevées sur GPU, parfois inenvisageables en suivant les préscriptions du constructeur.
+Nous verrons que cette notion d'\textit{occupancy}, si elle conserve du sens, peut toutefois être remise en question en optimisant d'autres aspects permettant d'arriver à une réduction de l'effet des latences, comme le parallélisme d'instructions ou l'augmentation du volume des transactions. En effet, ces techniques, et surtout l'utilisation avisée des différents types de mémoire du GPU permettent d'obtenir des performances élevées, parfois inenvisageables en suivant les prescriptions du constructeur.
+\section{Contraintes de conception}
+Certaines de ces contraintes ont déjà été évoquées rapidement, mais il nous semble important d'en donner ici une présentation synthétique.
+\begin{enumerate}
+\item \textbf{Contiguïté}.
+Les accès aléatoires à la mémoire globale sont en règle générale très pénalisants. Toutefois, il est possible de tirer parti du cache de niveau 1 (à une dimension) en organisant les données pour que tous les threads d'un même warp accèdent à des données appartenant au même bloc de 128 octets de mémoire. Le non-respect de cette contrainte de contiguïté (\textit{coalescence}) induit des accès réalisés en plusieurs transactions serialisées et donc une perte potentiellement importante de performances.
+\item \textbf{Conflits de banques}.
+La mémoire partagée, plus rapide que la mémoire globale, peut sembler une solution évidente pour obtenir des performances élevées. Cependant, elle est physiquement organisée en 32 \og banques \fg{} de largeur 32 bits et présente elle aussi une contrainte majeure. Sur architecture Fermi, l'exécution des threads d'un warp est assurée par deux \textit{moteurs d'exécution} qui activent en parallèle chacun des deux demi-warp.
+Un \textbf{conflit de banque} se produit lorsque deux threads n'appartenant pas au même demi-warp accèdent à des données localisées dans la même banque de mémoire partagée. La transaction parallèle est alors interrompue et sérialisée.
+Ici encore, la perte de performance peut être importante, mais il peut s'avérer très complexe, coûteux, voire impossible d'organiser les données en mémoire partagée de sorte à éviter tout conflit de banque.
+\item \textbf{Branches divergentes}.
+Toute branche d'exécution divergente entraine une sérialisation des exécution des threads du warp auquel ils appartiennent. Il convient donc d'éviter cette situation et d'organiser les traitements en conséquence en privilégiant le plus souvent un découpage plus fin en plusieurs kernels élémentaires plutôt que des kernels \og lourds \fg{}.
+\item \textbf{Transferts GPU$\leftrightarrow$CPU}.
+Les transferts de données entre la mémoire globale du GPU et celle de son hôte CPU peuvent représenter l'essentiel du temps de traitement total et doivent donc être optimisées pour en réduire la fréquence et le volume des données à copier. Cela peut parfois être contradictoire avec la multiplication de petits kernels élémentaires.
+Il est toutefois possible, lorsque la séquence de traitement le permet, de réaliser des transferts en temps masqué, pendant l'exécution d'un kernel, en créant plusieurs flux d'exécution.
+\item \textbf{Partage des ressources d'un SM}
+Le paragraphe sur l'\textit{occupancy} a abordé cet aspect par un exemple. Il faut retenir que chaque SM possède des ressources mémoire (registres, mémoire partagée) que les threads des blocs logiques de la grille de calcul se partagent au cours de l'exécution d'un kernel. L'équilibre entre l'utilisation de ces ressources et le dimensionnement de la grille de calcul relève d'un compromis parfois délicat à trouver pour obtenir les meilleures performances possibles.
+\item \textbf{Les latences}. L'exécution des kernels subit l'effet de latences d'origines diverses. Les latences d'accès aux mémoires (voir Table \ref{tab-gpu-memoire}), les latences des différentes instructions arithmétiques ou encore les latences crées par l'inter dépendance d'instructions consécutives. Il est impératif de les prendre en considération et de mettre en \oe uvre des techniques adaptées pour les masquer au mieux.
+\item \textbf{La mise au point}. L'ordonnancement des threads n'est pas prévisible et les quelques outils d'aide à la mise au point (debug) permettent simplement de cibler un thread présélectionné de la cible. Cela ne permet en aucun cas de déceler, par exemple, les conflits de banques provoqués par l'interaction d'au moins deux threads. Un outil de profilage développé par le fabricant fournit des informations importantes sur le nombre de conflits de banques et les origines probables des limitations de performance des kernels d'un programme. Il ne s'appuie cependant que sur un bloc de threads pour en extrapoler les résultats à l'ensemble de la grille.
+\end{enumerate}
+L'ensemble de ces aspects rend difficile la conception d'implémentations GPU rapides car rares sont les transcriptions directes d'un code CPU qui ne se heurtent pas sévèrement à l'une ou l'autre des contraintes que l'on vient d'énumérer. Les performances qui en résultent sont alors très en deça de celles attendues, voire inférieures à celles de l'implémentation CPU. La mise au point étant par ailleurs très délicate, il nous semble important de proposer des kernels élémentaires dont on peut aisément garantir les résultats par des méthodes de test ne nécessitant pas de devoir implémenter conjointement (et mettre au point) les versions CPU équivalentes des algorithmes concernés.