Vous êtes sur la page 1sur 102

Université de Liège

Faculté des Sciences Appliquées


Institut Montefiore

Implémentation et analyse des


performances d’algorithmes de calcul
scientifique sur GPU
Mémoire de fin d’études réalisé
Marsic Nicolas en vue de l’obtention du grade
d’Ingénieur Civil Électricien

Année académique 2010 — 2011


Résumé

Ce mémoire de fin d’études à pour sujet le calcul scientifique sur processeur graphique,
ou GPU. Ces nouvelles architectures sont, de plus en plus, exploitées à des fins autres que
graphiques, étant donné la parallélisation massive qu’elles offrent. L’objectif de ce travail est
d’avoir une vue plus claire de ce qu’il est judicieux de faire lors de l’utilisation de stations
équipées d’unités de traitement graphique. Ce mémoire commence par une description de
l’architecture matérielle des GPUs. Ensuite, le sujet de la programmation sera abordé. Dans
ce but, les langages CUDA et OpenCL seront présentés. En troisième partie, il sera question de
comparer les performances, sur CPU et sur GPU, de différentes opérations d’algèbre linéaire.
Enfin, ce travail se clôture sur l’implémentation GPU et l’étude des performances de trois
algorithmes de calcul scientifique : la vue adaptative, la méthode de Galerkin discontinue et
la trajectoire de particules chargées dans un champ de force électromagnétique.
Remerciements

C’est, avec un réel plaisir, que je tiens à remercier toutes les personnes, sans qui ce mémoire
n’aurait jamais vu le jour.
Tout d’abord, je tiens à remercier monsieur le professeur Christophe Geuzaine, pour
m’avoir proposé ce mémoire, pour m’avoir suivi tout au long de cette année, et pour son
enthousiasme.
Ensuite, je tiens à remercier monsieur David Colignon, pour m’avoir aidé durant tout mon
travail.
Je tiens également à remercier madame Brigitte Mausen, pour sa relecture méticuleuse.
Enfin, je remercie chaleureusement ma famille et mes amis, qui, au travers de leurs atten-
tions quotidiennes, m’ont soutenu durant toutes ces années.

ii
Table des matières

Introduction 1

1 Architecture d’un processeur graphique 2


1.1 Du rendu 3D au calcul scientifique . . . . . . . . . . . . . . . . . . . . . . . . 2
1.2 Vocabulaire . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
1.3 Architecture . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
1.4 Programmation d’un GPU . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
1.5 Threads GPU . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
1.6 Threads CPU . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
1.7 Mémoire . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
1.7.1 Mémoire globale . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
1.7.2 Mémoire partagée . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
1.7.3 Mémoire registre . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
1.7.4 Vue d’ensemble . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
1.7.5 Allocation des ressources mémoires . . . . . . . . . . . . . . . . . . . . 9
1.7.6 Transferts . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9
1.8 Paradigme SIMT . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
1.9 Les nombres en virgule flottante . . . . . . . . . . . . . . . . . . . . . . . . . 11
1.10 Le multi–GPU . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
1.11 Résumé . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12

2 Le langage CUDA 13
2.1 Répartition des threads . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
2.2 Codes CPU, GPU et kernel . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
2.3 Distinction entre les threads . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
2.4 Transferts mémoires . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
2.4.1 Allocation de la mémoire device . . . . . . . . . . . . . . . . . . . . . 16

iii
2.4.2 Copies entre host et device . . . . . . . . . . . . . . . . . . . . . . . . 17
2.5 Exemple récapitulatif . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19
2.6 CUDA Streams . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
2.6.1 Généralités . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
2.6.2 CUDA Streams . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
2.6.3 Synchronisation des tâches . . . . . . . . . . . . . . . . . . . . . . . . 23
2.6.4 Le parallélisme de tâche . . . . . . . . . . . . . . . . . . . . . . . . . . 24
2.6.5 Contrainte hardware . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25
2.7 Le multi–GPU . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
2.8 Synchronisation et opérations atomiques . . . . . . . . . . . . . . . . . . . . . 28
2.9 Compilation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
2.10 Mécanisme de programmation de plus bas niveau . . . . . . . . . . . . . . . . 29

3 Le langage OpenCL 30
3.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30
3.2 Vocabulaire . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 31
3.3 Code kernel . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 31
3.4 Code host . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32
3.4.1 Contexte OpenCL . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32
3.4.2 File de commandes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33
3.4.3 Compilation du kernel . . . . . . . . . . . . . . . . . . . . . . . . . . . 33
3.4.4 Allocation et transferts mémoires . . . . . . . . . . . . . . . . . . . . . 34
3.4.5 Appel du kernel . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 35
3.4.6 Libération des ressources . . . . . . . . . . . . . . . . . . . . . . . . . 36
3.5 Un exemple complet . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36

4 Étude des performances 37


4.1 Notions complémentaires . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37
4.1.1 Librairies BLAS . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37
4.1.2 Cartes NVIDIA Tesla . . . . . . . . . . . . . . . . . . . . . . . . . . . 37
4.2 Matériel utilisé . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37
4.2.1 Plateforme desktop . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 38
4.2.2 Plateforme fermi . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
4.2.3 Plateforme lmgpu . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40
4.2.4 Plateforme gameboy . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41

iv
4.3 Multiplication matricielle : cas des matrices carrées . . . . . . . . . . . . . . . 41
4.3.1 Implémentations naïves . . . . . . . . . . . . . . . . . . . . . . . . . . 42
4.3.2 BLAS . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43
4.3.3 CUBLAS . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 44
4.3.4 Scaling . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 46
4.3.5 Transferts mémoires . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47
4.3.6 Multi–GPU . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47
4.3.7 Double Précision . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 51
4.4 Multiplication matricielle : matrice carrée & matrice rectangulaire . . . . . . 53
4.5 Multiplication matrice–vecteur . . . . . . . . . . . . . . . . . . . . . . . . . . 56
4.6 Multiplication vecteur–vecteur . . . . . . . . . . . . . . . . . . . . . . . . . . 59
4.7 Résumé & Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 60

5 Problème de la vue adaptative 62


5.1 Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 62
5.2 Implémentation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 63
5.2.1 Base de travail . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 63
5.2.2 Classe fullMatrix . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 63
5.3 Intégration du code CUBLAS dans gmsh . . . . . . . . . . . . . . . . . . . . . . 65
5.4 Résultats . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 65
5.4.1 Présentation du cas de test . . . . . . . . . . . . . . . . . . . . . . . . 65
5.4.2 Étude des performances . . . . . . . . . . . . . . . . . . . . . . . . . . 66
5.5 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 67

6 Méthode de Galerkin discontinue 68


6.1 Description de la méthode de Galerkin discontinue . . . . . . . . . . . . . . . 68
6.1.1 Méthode des éléments finis & Galerkin continu . . . . . . . . . . . . . 68
6.1.2 La méthode de Galerkin discontinue . . . . . . . . . . . . . . . . . . . 69
6.2 Portage GPU du code dg : premier essai . . . . . . . . . . . . . . . . . . . . . . 69
6.2.1 Implémentation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69
6.2.2 Étude des performances . . . . . . . . . . . . . . . . . . . . . . . . . . 70
6.3 Portage GPU du code dg : second essai . . . . . . . . . . . . . . . . . . . . . . 71
6.3.1 Amélioration par rapport au premier portage . . . . . . . . . . . . . . 71
6.3.2 Implémentation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 72
6.3.3 Étude des performances . . . . . . . . . . . . . . . . . . . . . . . . . . 75

v
6.4 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 77

7 Trajectoires de particules chargées dans un champ de force électromagné-


tique 78
7.1 Présentation du problème . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 78
7.2 Résolution . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 79
7.2.1 Algorithme de Beeman . . . . . . . . . . . . . . . . . . . . . . . . . . . 79
7.2.2 Implémentation CUDA . . . . . . . . . . . . . . . . . . . . . . . . . . . . 79
7.2.3 Implémentation CPU . . . . . . . . . . . . . . . . . . . . . . . . . . . . 81
7.3 Résultats . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 81
7.3.1 Occupation du GPU . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 81
7.3.2 Performance . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 82
7.4 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 85

Conclusion 86

A Résolution éléments finis de l’équation de Poisson 1D 88

Bibliographie 92

vi
Table des figures

1.1 Utilisation de la surface d’une puce pour un CPU et un GPU (d’après [12]) . . . 3
1.2 Bloc diagramme de l’architecture de calcul G80 (d’après [7]) . . . . . . . . . . 4
1.3 Architecture d’un streaming multiprocessor G80 (d’après [5]) . . . . . . . . . 5
1.4 Appel d’un kernel GPU . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
1.5 Vue logique de la mémoire d’un GPU (d’après [5]) . . . . . . . . . . . . . . . . 8
1.6 Architecture d’un streaming multiprocessor G80 (d’après [5]) . . . . . . . . . 8

2.1 Grilles de dimension 1, 2 et 3 . . . . . . . . . . . . . . . . . . . . . . . . . . . 14


2.2 Exemple d’exécution (d’après [12]) . . . . . . . . . . . . . . . . . . . . . . . . 15
2.3 Division en warp des blocs de dimension supérieure à un (warp de taille 2) . . 15
2.4 Illustration du principe de pipeline (cas de deux copy engines) . . . . . . . . . 22
2.5 État des engines pour la première tentative de pipeline (d’après [20]) . . . . . 26
2.6 Organisation des engines conduisant à un pipeline (d’après [20]) . . . . . . . . 26

4.1 Photographie d’une carte graphique NVIDIA 8500 GT . . . . . . . . . . . . . 38


4.2 Photographie d’une carte graphique NVIDIA GT430 . . . . . . . . . . . . . . 39
4.3 Trois implémentations naïves de la multiplication matricielle (desktop) . . . . 42
4.4 Comparaison entre un algorithme CUDA naïf et un algorithme CPU optimisé
(desktop) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43
4.5 Comparaison entre BLAS et CUBLAS (desktop) . . . . . . . . . . . . . . . . . . 44
4.6 Performance de CUBLAS pour une évolution progressive des matrices (lmgpu) . 45
4.7 Effet de scaling (GeForce 8500GT & Tesla C1060) . . . . . . . . . . . . . . . . 46
4.8 Temps de transferts mémoires (gameboy) . . . . . . . . . . . . . . . . . . . . . 47
4.9 Répartition des matrices pour 2 GPUs . . . . . . . . . . . . . . . . . . . . . . . 48
4.10 Répartition des matrices pour 4 GPUs . . . . . . . . . . . . . . . . . . . . . . . 48
4.11 Accélération apportée par le multi-GPU . . . . . . . . . . . . . . . . . . . . . . 49
4.12 Distribution de la charge entre les GPUs . . . . . . . . . . . . . . . . . . . . . 49

vii
4.13 Accélération apportée par le multi-GPU (en GFLOPS) . . . . . . . . . . . . . . . 50
4.14 Passage de la simple à la double précision (lmgpu) . . . . . . . . . . . . . . . 51
4.15 Passage de la simple à la double précision (fermi) . . . . . . . . . . . . . . . . 52
4.16 Passage de la simple à la double précision : comparaisons CPU / GPU . . . . . 53
4.17 Cas de la multiplication d’une matrice carrée par une matrice rectangulaire . 53
4.18 Étude des performances dans le cas de la multiplication de matrices [N × N ] ×
[N × 6] (fermi) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 54
4.19 Étude des performances dans le cas de la multiplication de matrices [N × N ] ×
[N × 6] : temps de transferts (fermi) . . . . . . . . . . . . . . . . . . . . . . . 55
4.20 Étude des performances dans le cas de la multiplication de matrices [N × N ] ×
[N × 6] : variation de N par pas unitaire (fermi) . . . . . . . . . . . . . . . . 56
4.21 Étude des performances dans le cas de la multiplication d’une matrice carrée
par un vecteur (fermi) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57
4.22 Étude des performances dans le cas de la multiplication d’une matrice carrée
par un vecteur : temps de transfert (fermi) . . . . . . . . . . . . . . . . . . . 58
4.23 Étude des performances dans le cas de la multiplication d’une matrice carrée
par un vecteur : variation de la dimension par pas unitaire (fermi) . . . . . . 58
4.24 Étude des performances dans le cas de la multiplication vecteur–vecteur (fermi) 59
4.25 Principaux résultats en termes de performances . . . . . . . . . . . . . . . . . 61

5.1 Illustration du principe de vue adaptative . . . . . . . . . . . . . . . . . . . . 63


5.2 Cas test sans vue adaptative (d’après [19]) . . . . . . . . . . . . . . . . . . . . 65
5.3 Division d’un triangle en sous-triangles . . . . . . . . . . . . . . . . . . . . . . 66
5.4 Cas test avec vue adaptative (d’après [19]) : division, de plus en plus fine, des
domaines problématiques (la dernière figure représente la solution finale sans
son maillage) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 66
5.5 Comparaison des performances . . . . . . . . . . . . . . . . . . . . . . . . . . 67

6.1 Comparaisons des performances entre la version CPU et la version GPU, avec
pipeline, de dg . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 76

7.1 Champs électrique et d’induction magnétique (premier cas test) . . . . . . . . 82


7.2 Comparaison entre les versions CPU et GPU (premier cas test) . . . . . . . . . 83
7.3 Résultats du second cas test . . . . . . . . . . . . . . . . . . . . . . . . . . . . 84

A.1 Domaine d’étude . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 88


A.2 Domaine discrétisé . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 88
A.3 Fonction de forme associée au nœud 2 . . . . . . . . . . . . . . . . . . . . . . 89

viii
Liste des tableaux

1.1 Taille des différentes mémoires pour l’architecture G80 (d’après [22]) . . . . . 9
1.2 Exemple d’exécution du code d’addition vectorielle . . . . . . . . . . . . . . . 10

3.1 Comparaison entre les termes CUDA et OpenCL (d’après [5]) . . . . . . . . . . . 31


3.2 Termes OpenCL désignant le matériel (d’après [5]) . . . . . . . . . . . . . . . . 31
3.3 Fonctions identifiant les working items (d’après [5]) . . . . . . . . . . . . . . . 32
3.4 Fonctions pour libérer les ressources . . . . . . . . . . . . . . . . . . . . . . . 36

4.1 Logiciels disponibles sur desktop . . . . . . . . . . . . . . . . . . . . . . . . . 39


4.2 Logiciels disponibles sur fermi . . . . . . . . . . . . . . . . . . . . . . . . . . . 40
4.3 Logiciels disponibles sur lmgpu . . . . . . . . . . . . . . . . . . . . . . . . . . 40
4.4 Logiciels disponibles sur gameboy . . . . . . . . . . . . . . . . . . . . . . . . . 41

6.1 Temps d’initialisation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 71

7.1 Paramètres du plugin CULorentz (premier cas test) . . . . . . . . . . . . . . . 82


7.2 Temps d’exécutions pour 49 particules (second cas test) . . . . . . . . . . . . 85
7.3 Temps d’exécutions pour 65 particules (second cas test) . . . . . . . . . . . . 85

ix
Introduction

Poussées par une industrie du jeu vidéo de plus en plus exigeante, les cartes graphiques
modernes, communément appelées GPUs, pour Graphics Processing Units, sont devenues de
véritable plateformes de calcul intensif. Partant de ce constat, des sociétés comme NVIDIA
ou AMD, se sont mises à développer des architectures, permettant le développement et l’exé-
cution de codes généraux sur GPU. Cette nouvelle approche de la programmation porte le nom
de General-Purpose computing on Graphics Processing Units, ou GPGPU.
L’attrait des processeurs graphiques, dans la résolution de problèmes de calcul scientifique,
réside dans deux points importants. Tout d’abord, comme nous le verrons dans ce travail, les
GPUs nous offrent une architecture massivement parallèle. Ainsi, si nous trouvions un moyen
d’exploiter efficacement les GPUs, nous pourrions accélérer significativement une large gamme
de codes de calculs. Deuxièmement, l’intérêt des cartes graphiques réside dans leur faible coût,
par rapport au prix d’un microprocesseur classique.
C’est pourquoi, ces dernières années, un grand nombre de travaux ont émergé sur le sujet
de la programmation sur GPU. Notons également que le super-calculateur chinois Tianhe-1A,
classé premier au rang mondial en novembre 2010 1 , n’embarque pas moins de 7168 cartes
graphiques NVIDIA Tesla M2050.
Dans le cadre de ce travail, nous nous proposons d’explorer les différentes possibilités
offertes par ces architectures. Ainsi, nous espérons obtenir une vue plus claire de ce qu’il
convient de faire, lorsque nous sommes face à des stations de calcul, embarquant un, ou
plusieurs, processeurs graphiques.
Ce document est divisé en 7 chapitres.
• Chapitre 1 : présentation de l’architecture matérielle des processeurs graphiques
• Chapitre 2 : présentation du langage CUDA
• Chapitre 3 : présentation du langage OpenCL
• Chapitre 4 : étude des performances offertes par les GPUs, pour différentes opérations
d’algèbre linéaire
• Chapitre 5 : implémentation GPU et étude d’un algorithme de vue adaptative
• Chapitre 6 : implémentation GPU et étude d’un algorithme de calcul par la méthode
de Galerkin discontinue
• Chapitre 7 : implémentation GPU et étude d’un algorithme de calcul de trajectoires
de particules chargées dans un champ de force électromagnétique

1. Voir : http://www.top500.org/

1
Chapitre 1

Architecture d’un processeur


graphique

Avant d’aborder le sujet du calcul sur GPU, il importe de connaitre les bases de l’architec-
ture matérielle utilisée. En effet, celle-ci est substantiellement différente d’une architecture x86
classique.
Nous entrerons dans le détail des architectures NVIDIA GeForce. Cependant, notons que
AMD (anciennement ATI) développe également des plateformes de calcul scientifique sur GPU.
Citons l’architecture HD6000, la dernière–née des laboratoires AMD.
Cette introduction est principalement basée sur les références suivantes : [7, 12, 5]. La sec-
tion 1.7 est, quant à elle, inspirée de [22].

1.1 Du rendu 3D au calcul scientifique

La tâche la plus importante des GPUs modernes est le rendu 3D. Nous pouvons décomposer
cette tâche en deux grandes étapes :
1. La projection de la représentation 3D d’une scène sur un plan 2D
2. La pixellisation de l’image 2D
Tout le hardware d’un GPU s’articule autour de ces deux objectifs.
A l’aube du traitement graphique, les algorithmes de rendu étaient encodés une fois pour
toutes sur la puce. En d’autres termes, aucune modification des algorithmes ne pouvait être
apportée. Très vite, les constructeurs ont rendu certaines parties du processeur program-
mables : le calcul sur GPU était né.
L’avantage avec l’utilisation de processeurs graphiques, pour résoudre des problèmes de
calcul intensif, est de pouvoir tirer parti du degré élevé de parallélisme de ces processeurs.
En effet, le rendu graphique se parallélisant assez naturellement, les constructeurs ont décidé
d’opter pour une architecture mettant en scène un très grand nombre d’unités de calcul mises
en parallèle. Cette approche est très différente de ce qui est choisi pour un CPU. Pour celui-ci,
une grande partie de la surface de la puce est dédiée à la mémoire et au contrôle. Ainsi, seule

2
une faible portion du CPU est dédiée au calcul proprement dit. La figure 1.1 reprend ce qui
vient d’être dit.

Figure 1.1 – Utilisation de la surface d’une puce pour un CPU et un GPU (d’après [12])

Avec les premiers GPUs programmables, les problèmes devaient être formulés sous la forme
d’opérations de rendu. Ceci rendait difficile le développement d’algorithmes de calculs géné-
raux. En 2007, NVIDIA lance CUDA (Compute Unified Device Architecture), une technologie
permettant de programmer les GPUs via le langage C.

1.2 Vocabulaire

Tout au long de ce travail, nous serons amenés à parler de GPU et de CPU. Physiquement, ces
termes ne désignent que des processeurs. Cependant, ceux-ci nécessitent souvent des dispositifs
supplémentaires, tels que de la mémoire. Nous avons donc besoin de termes pour désigner ces
ensembles plus généraux.
Nous appellerons device, l’ensemble constitué :
• du GPU
• de mémoires RAM propres au GPU
• d’une interface avec une carte mère 1
Nous appellerons host, l’ensemble constitué :
• du CPU
• de mémoires RAM accessibles depuis le CPU
• de la carte mère

1.3 Architecture

L’architecture d’un GPU moderne est basée sur une grille programmable. Les éléments de
cette grille sont les Streaming Processors, ou SPs. Une illustration est disponible à la figure 1.2.
Notons qu’un SP est capable d’effectuer une opération réelle d’addition et de multiplication
en un cycle 2 .
1. Comme nous le verrons plus tard, il s’agit d’un lien PCI Express
2. Comprendre : R = A × B + C et non R = A + C ou R = A × B A, B, C ∈ R

3
Host

Figure 1.2 – Bloc diagramme de l’architecture de calcul G80 (d’après [7])

4
Les streaming processors sont groupés par Streaming Multiprocessors, également appelés
SM. En plus des SPs, ceux-ci offrent une cache d’instructions, un circuit d’instruction fetch, de
la mémoire partagée, de la mémoire registre et des unités de calcul de fonctions transcendantes,
appelées SFUs (Special Function Units). Une illustration est disponible à la figure 1.3.

Instruction cache

Figure 1.3 – Architecture d’un streaming multiprocessor G80 (d’après [5])

L’architecture G80 possède 16 streaming multiprocessors, contre 30 pour l’architecture


GT200.
Notons que la dernière architecture née des laboratoires NVIDIA, l’architecture Fermi,
possède 32 SPs par SM (d’après [11]). Pour ce qui est des générations antérieures, elles ne
disposent que de 8 SPs par SM. Notons que les nombres annoncés ne sont valables qu’en
simple précision. Le sujet de la double précision est abordé à la section 1.9.

1.4 Programmation d’un GPU

Au niveau du GPU, nous disposons d’un ensemble de langages, nous permettant de le


programmer. Les langages les plus connus sont CUDA et OpenCL. Nous approfondirons ces
langages aux chapitres suivants.
Au niveau du CPU, nous disposons d’un ensemble de fonctions permettant de contrôler le
GPU. Il existe différentes librairies permettant ce contrôle. Celles-ci sont spécifiques au langage
utilisé pour programmer le GPU.
Grâce à ces fonctions, le CPU est capable de démarrer l’exécution d’un code sur le GPU.
Nous avons donc un code, appelé depuis le CPU et lancé sur le GPU. Un tel code est appelé
kernel.
Au niveau du CPU, l’appel d’un kernel est toujours asynchrone : une fois le kernel invoqué,
le CPU passe directement à l’instruction suivante, que le kernel ait fini son exécution ou non.
En plus de demander le lancement d’un kernel, le CPU a l’obligation de donner le nombre
d’exécutions désiré du kernel. Ces exécutions seront, autant que faire se peut, parallèles.

5
Les différents fils d’exécution du kernel sont appelés threads. La figure 1.4 résume ce dernier
paragraphe.

CPU

Figure 1.4 – Appel d’un kernel GPU

Notons que, puisque le CPU a l’obligation de donner le nombre d’exécutions du kernel,


ce nombre doit être connu avant l’appel ! En d’autres termes, il est impossible de modifier a
posteriori le nombre de thread.

1.5 Threads GPU

Il nous faut encore comprendre comment les threads sont organisés et exécutés au niveau
hardware.
Lors de l’appel du kernel, les threads sont divisés en blocs. Les nombres de blocs et de
threads par bloc sont spécifiés à l’appel du kernel.
Ensuite, chaque bloc est accroché à un SM. Un maximum de 8 blocs peuvent être liés à
un même SM. Les blocs ne trouvant aucun multiprocessor libre sont mis en file d’attente. Un
bloc est décroché d’un SM dès que tous ses threads ont finis leurs exécutions.
En plus de la limitation de 8 blocs par SM, celui-ci ne peut ordonnancer qu’un nombre
limité de warps :
• maximum 24 warps pour l’architecture G80
• maximum 32 warps pour l’architecture GT200
• maximum 48 warps pour l’architecture GF100 3 (d’après [15])
Les threads d’un bloc sont divisés en groupes de 32, appelés warps. Notons qu’un bloc
peut contenir un maximum de 512 threads, soit 16 warps.
Le nombre de threads résidant dans un SM peut être supérieur au nombre de SPs. Les
threads sont alors ordonnancés temporellement entre les SPs.
Dans un même warp, afin d’obtenir une exécution purement parallèle, les threads ne
peuvent pas diverger dans leurs instructions (par exemple, à cause d’un branchement condi-
tionnel). Si nous nous retrouvons dans cette configuration, les threads sont divisés en groupes
suivant les différents chemins d’exécution possibles. Ensuite, les groupes sont exécutés en
série 4 . Si jamais la divergence apparait au sein de warps différents, elle n’a aucun effet.
3. Première génération de cartes graphiques basées sur l’architecture Fermi
4. Notons que les threads sont exécutés en parallèle au sein d’un même groupe

6
Ce phénomène s’explique par la présence d’un seul circuit d’instruction fetch par SM (voir
figure 1.3). En conséquence, tous les SPs voient la même instruction à exécuter.
Remarquons que si seuls 8 SPs par SM sont disponibles 5 , alors l’exécution des 32 instruc-
tions courantes d’un warp prend 4 cycles. Dans le cas de l’architecture Fermi, avec ses 32 SPs
par SM, un seul cycle est nécessaire.
Avoir en permanence plusieurs warps à disposition permet au SM d’être toujours actif :
c’est-à-dire, de ne pas être bloqué par un warp, par exemple en attente de données provenant
de la mémoire.
Pour terminer, les architectures G80 à GT200 ne peuvent exécuter qu’un seul kernel à la
fois. L’architecture Fermi peut, quant à elle, gérer jusqu’à 16 kernels concurrents.

1.6 Threads CPU

Nous serons également amenés à parler de threads dans le contexte des codes sur CPU.
Dans ce cadre, le concept de thread est légèrement différent : il s’agit d’un fil d’exécution,
ayant son propre flux de contrôle (d’après [23]).
Nous pouvons en retirer que la différence fondamentale, entre threads CPU et GPU, est dans
le flux de contrôle. Les threads actifs du CPU ont leur propre flux de contrôle, ce qui n’est pas
le cas pour un thread GPU. Rappelons que les threads d’un même SM partagent le même circuit
d’instruction fetch.

1.7 Mémoire

Dans les architectures des processeurs graphiques modernes, la mémoire peut être divisée
en trois niveaux : mémoire globale, mémoire partagée et mémoire registre.

1.7.1 Mémoire globale

La mémoire globale est de type DRAM. Il s’agit d’une mémoire de très grande capacité,
mais à accès lent.
Cet étage de mémoire est accessible par tous les threads.

1.7.2 Mémoire partagée

Comme nous l’avons vu à la section 1.3, chaque SM dispose d’une mémoire, dite partagée,
de taille fixe. Cette mémoire est partagée par tous les threads d’un même bloc.
Étant donné qu’un SM peut ordonnancer plusieurs blocs, cette mémoire devra être répartie
entre les blocs d’un même SM.
5. Dans le cas des architectures non Fermi

7
1.7.3 Mémoire registre

Comme nous l’avons vu à la section 1.3, chaque SM dispose d’une mémoire, dite registre,
de taille fixe. Cette mémoire est privée à chaque thread.
Étant donné qu’un SM peut ordonnancer plusieurs threads, cette mémoire sera répartie
entre les threads d’un même SM.

1.7.4 Vue d’ensemble

La figure 1.5 résume, d’un point de vue logique, ce qui a été appris.

Shared
Memory

Figure 1.5 – Vue logique de la mémoire d’un GPU (d’après [5])

La figure 1.3 résume, d’un point de vue matériel, ce qui a été appris. Dans un souci de
clarté, ce schéma est repris à la figure 1.6.

Instruction cache

Figure 1.6 – Architecture d’un streaming multiprocessor G80 (d’après [5])

8
A titre d’exemple, nous reprenons à la table 1.1 la taille des différentes mémoires de
l’architecture G80.
Mémoire globale 6 banques de 128 MB
Mémoire partagée 16 kB par SM
Mémoire registre 32 kB par SM

Table 1.1 – Taille des différentes mémoires pour l’architecture G80 (d’après [22])

1.7.5 Allocation des ressources mémoires

Lors de l’attachement d’un bloc à un SM, les ressources mémoires pour tous les threads
du bloc sont directement allouées. Rappelons qu’un SM peut ordonnancer plusieurs blocs : les
ressources mémoires de leurs threads seront également allouées.
Cette allocation directe des ressources permet un ordonnancement rapide des threads et
des blocs, mais limite les nombres de threads par bloc et de blocs par SM. Par exemple, si les
threads résidant dans le SM sont trop exigeants en termes de mémoire registre, le nombre de
blocs sera dynamiquement réduit.
Il est très important de noter que la réduction des ressources est effectuée en diminuant le
nombre de blocs : l’utilisation de grands blocs, occupant beaucoup de mémoire, peut conduire
à une utilisation sous-optimale du processeur graphique.
Illustrons les deux derniers paragraphes par un exemple. Imaginons que le système veuille
associer 3 blocs de 256 threads, soit 24 warps au total, à un SM. Supposons que chaque thread
alloue un tableau en mémoire registre de 11 flottants 6 . Nous avons donc 44 octets de registres
par thread, soit environ 33.8 kB de mémoire registre allouée pour le SM. Or, un SM ne possède
que 32 kB de mémoire registre : le système ne pouvant pas allouer autant de mémoire, celui-ci
n’acceptera que 2 blocs et donc seulement 16 warps.

1.7.6 Transferts

Des dispositifs de copie entre la mémoire RAM host et la RAM device sont implémentés. No-
tons que ces copies peuvent être synchrones ou asynchrones. L’avantage des copies asynchrones
est le suivant : le code kernel peut continuer son exécution pendant que des chargements, un
montant et/ou 7 un descendant, ont lieu. On parle alors de parallélisme de tâches.
Les transferts mémoires s’effectuent au travers d’un bus PCI Express 16X. Ce bus possède
une bande passante théorique, simultanément en montant et en descendant, de 8 GB/s dans
sa version 2 (d’après [3]).
Remarquons que la plateforme NVIDIA Ion, architecture de carte graphique intégrée à
la carte mère, ne possède pas sa mémoire RAM propre : cette dernière partage la même mé-
moire que l’host. Ceci implique qu’aucun transfert par le bus PCI Express n’est nécessaire
(d’après [20]).
6. Dans ce cas-ci, nous entendons par flottant, un nombre en virgule flottante simple précision : soit 4 octets
(voir section 1.9)
7. Certaines architectures peuvent exploiter simultanément le bus en montant et en descendant

9
1.8 Paradigme SIMT

Dans la section sur l’architecture, nous avons affirmé que, pour avoir une exécution pa-
rallèle, les threads d’un warp ne doivent pas diverger dans leurs instructions. Mais si tous les
threads doivent exécuter les mêmes instructions, quel est l’intérêt d’une telle architecture ?
La réponse est très simple : bien que les threads ne peuvent pas diverger dans leurs
instructions, ceux-ci peuvent diverger dans les arguments de ces instructions.
Pour illustrer ce propos, prenons l’exemple de la somme de deux vecteurs : chaque thread
va effectuer la même opération d’addition, mais avec des éléments différents.
Addition vectorielle

Soient trois vecteurs a, b et c de dimension 4 (supposons un warp de taille 4 pour


l’exemple).
Nous pouvons écrire le pseudo-code suivant :
void main ( void ){
float a [4];
float b [4];
float c [4];

CopyArrayOnGPU ( a );
CopyArrayOnGPU ( b );

LaunchKernel ( ’4 Times ’ , ’ VectAdd ’ , a , b , c );

GetArrayFromGPU ( c );
}

void VectAdd ( float *a , float *b , float * c ){


int i = GetThreadId ();

c [ i ] = a [ i ] + b [ i ];
}

La table 1.2 illustre l’exécution du kernel VectAdd.


Thread 0 Thread 1 Thread 2 Thread 3
c[0] = a[0] + b[0] c[1] = a[1] + b[1] c[2] = a[2] + b[2] c[3] = a[3] + b[3]

Table 1.2 – Exemple d’exécution du code d’addition vectorielle

Nous remarquons que les 4 threads exécutent une opération d’addition : ces 4 additions
se feront donc en parallèle.
Cette approche porte le nom de SIMT, pour Single Instruction Multiple Thread.

10
1.9 Les nombres en virgule flottante

Une grande évolution dans la technologie des GPUs est l’adoption du standard IEEE 754.
Rappelons que celui-ci est utilisé dans la représentation des nombres réels. Il existe différents
degrés de précision, suivant le nombre de bits utilisés par réel :
• 32 bits pour les nombres en simple précision
• 64 bits pour les nombres en double précision
Les premières architectures CUDA, telles que la G80, n’implémentaient que la version simple
précision. Les architectures plus récentes adoptent, quant à elles, les deux versions.
Remarquons que, même si certains GPUs implémentent la double précision, les calculs en
double précision sont largement plus lents que ceux en simple précision.
Ce phénomène s’explique par le fait que les SMs embarquent moins de SPs capables de
double précision :
• l’architecture GT200 ne compte que 1 SP par SM en double précision, contre 8 SP par
SM en simple précision (d’après [8])
• l’architecture GF100 ne compte que 16 SPs par SM en double précision, contre 32 SPs
par SM en simple précision (d’après [11])
Après lecture du dernier paragraphe, nous sommes en droit de nous poser la question
suivante : les SPs en simple et double précision sont-ils physiquement séparés, ou s’agit-il
d’une valeur équivalente ? Helas, cette question reste sans réponses, étant donné que NVIDIA
n’aborde jamais la question de la réalisation physique.
Cependant, en recoupant les références [8], [15] et [11], nous pouvons penser que pour
l’architecture GT200, le SP en double précision est effectivement physiquement séparé des SPs
en simple précision. Par contre, l’architecture GF100 semble unifiée. Encore une fois, rappelons
qu’il ne s’agit que d’une hypothèse, NVIDIA n’abordant jamais le sujet de la réalisation
physique.

1.10 Le multi–GPU

Afin d’augmenter les performances en termes de rendu, les constructeurs de GPUs ont
imaginé des dispositifs utilisant plusieurs cartes graphiques.
Remarquons que différents liens propriétaires 8 ont été mis au point afin d’obtenir une
répartition dynamique du rendu entre les GPUs. Il est important de noter ces liens ne per-
mettent pas d’échanges directs de segments de mémoire entre les cartes graphiques. Donc,
si dans le cadre d’un code de calcul, des données doivent être échangées entre les devices,
celles-ci doivent passer par le bus PCI Express.
Au niveau de la programmation, nous pouvons demander aux différents GPUs d’exécuter
le même kernel. Les données sont alors réparties entre les GPUs.
8. Citons les liaisons NVIDIA SLI (http://www.slizone.com/) et AMD CrossFireX (http://game.amd.
com/us-en/crossfirex_about.aspx)

11
1.11 Résumé

Les concepts vus dans les sections précédentes étant importants, nous nous proposons des
les résumer dans les lignes suivantes :
• Nous appelons device l’ensemble constitué du GPU, de sa mémoire RAM propre, et d’une
interface avec la carte mère.
• Nous appelons host l’ensemble constitué du CPU, de sa mémoire RAM, et de la carte
mère.
• L’unité de base d’un processeur graphique est le streaming processor, aussi appelé SP.
• Un SP est capable d’opérations réelles, du type R = A × B + C, en un cycle.
• Un streaming multiprocessor, aussi appelé SM, est un groupement de SPs accompagné
de mémoire et d’un circuit d’instruction fetch.
• Ce circuit d’instruction fetch est commun pour tous les SPs du SM.
• Les GPUs Fermi disposent de 32 SPs par SM, contre 8 pour les générations antérieures.
• Les threads à exécuter sur le GPU sont divisés en blocs, avec maximum 512 threads
par bloc.
• Un bloc est également divisé en groupes de 32 threads, appelés warps.
• Un SM peut ordonnancer simultanément un maximum de 8 blocs.
• En plus de cette limite de blocs, un SM ne peut ordonnancer qu’un maximum de 24
warps pour l’architecture G80, de 32 pour la GT200 et de 48 pour la GF100.
• Les SMs exécutent les threads d’un warp en parallèle, tant que ces threads ne divergent
pas.
• Si les threads d’un warp divergent, alors les différents chemins d’exécution sont exé-
cutés en série, mais en parallèle au sein du même chemin.
• Si les warps divergent entre eux, cela n’a aucune conséquence sur l’exécution parallèle.
• Les blocs non liés à un SM sont en liste d’attente.
• Les ressources mémoires sont allouées pour tous les threads d’un bloc dès qu’un bloc
est lié à un SM.
• Cette allocation directe permet un ordonnancement rapide des threads, mais limite
le nombre de threads par bloc et de blocs par SM.
• Les threads ne peuvent pas diverger en termes d’instructions, mais peuvent diverger
dans les arguments de ces instructions.
• Cette dernière approche porte le nom de Single Instruction Multiple Thread, aussi
appelé SIMT.
• Les réels sont représentés suivant le standard IEEE 754.
• La double précision n’est disponible que sur les architectures plus récentes.
• Le temps de calcul en double précision augmente largement par rapport à la simple
précision.
• Les échanges de mémoires entre les GPUs doivent passer par le bus PCI Express.

12
Chapitre 2

Le langage CUDA

CUDA est une technologie permettant la réalisation de calculs scientifiques sur GPU. Celle-ci est
le fruit des laboratoires de la société NVIDIA. Le vocable CUDA est utilisé pour désigner à la fois
le hardware et le langage de programmation.
Ce chapitre est consacré à la présentation du langage CUDA, le hardware ayant déjà été présenté
au chapitre précédent.
Ce chapitre est basé sur les références suivantes : [5, 12, 20]. Notons que toutes les spécifi-
cations des fonctions CUDA sont reprises dans [14], et que les spécifications du compilateur sont
reprises dans [10].

2.1 Répartition des threads

Nous avons déjà vu, dans le chapitre consacré à l’architecture matérielle, comment les
threads étaient organisés. Nous allons maintenant voir comment programmer le GPU pour
paramétrer les nombres de blocs et de threads par bloc.
Du point de vue logiciel, un appel du kernel crée une grille. Cette grille peut être de
dimension 1, 2 ou 3, et est divisée en blocs. Les nombres de blocs par grille et de threads par
bloc sont spécifiés à l’appel du kernel. Une représentation graphique de ce qui vient d’être
présenté est disponible figure 2.1.
Au niveau du langage CUDA, un kernel est appelé depuis le CPU par le code suivant :
MyKernel < < < NumberOfBlocks , ThreadsPerBlock > > >( Arg0 , Arg1 , ...);

Les variables NumberOfBlocks et ThreadsPerBlock sont du type dim3, type spécifique à


CUDA. Les variables de ce type possèdent trois champs. Si seul le premier champ est affecté, la
grille sera à une dimension. Si les deux premiers champs sont affectés, la grille sera de dimen-
sion deux. Et finalement, si les trois champs sont affectés, la grille sera à trois dimensions.
Exemples d’utilisation des variables de type dim3

dim3 NumberOfBlocks (256); // 1 D grid of 256 blocks


dim3 NumberOfBlocks (16 , 16); // 2 D grid of 16 X 16 blocks

13
dim3 NumberOfBlocks (16 , 16 , 2); // 3 D grid of 16 X 16 X 2 blocks

Grid 0

Block (0, 0) Block (0, 1) Block (0, 2)

Block (1, 0) Block (1, 1) Block (1, 2)

Grid 0
Block (2, 0) Block (2, 1) Block (2, 2)
Block 0 Block 1 Block 2 Block 3

Grid 0
Block (0, 0, 0) Block (0, 1, 0) Block (0, 0, 1) Block (0, 1, 1)

Block (1, 0, 0) Block (1, 1, 0) Block (1, 0, 1) Block (1, 1, 1)

Figure 2.1 – Grilles de dimension 1, 2 et 3


Remarquons qu’au niveau du bloc, nous avons également une organisation à 1, 2 ou 3
dimensions.
Exemple d’un lancement de kernel

Pour cet exemple, supposons des warps de taille 4. La commande :


dim3 ThreadsPerBlock (2 , 2);
dim3 NumberOfBlocks (2 , 2);
MyKernel < < < NumberOfBlocks , ThreadsPerBlock > > >();

lancera 16 threads, organisés selon la figure 2.2.

Il est important de noter que la taille des warps reste de 32, quelle que soit la dimension du
bloc. Les blocs de dimension 2 et 3 diviseront leurs threads en groupes de 32 dans un espace
linéaire. La figure 2.3 reprend ce qui vient d’être dit pour une taille de warp, hypothétique,
de 2.

14
Block(0, 0) Block(0, 1)

Thread (0, 0) Thread (0, 1) Thread (0, 0) Thread (0, 1)

Thread (1, 0) Thread (1, 1) Thread (1, 0) Thread (1, 1)

Block(1, 0) Block(1, 1)

Thread (0, 0) Thread (0, 1) Thread (0, 0) Thread (0, 1)

Thread (1, 0) Thread (1, 1) Thread (1, 0) Thread (1, 1)

Figure 2.2 – Exemple d’exécution (d’après [12])

Threads
(0, 0) (0, 1) Warp Space
(1, 0) (1, 1) (0, 0)
(2, 0) (2, 1)

Figure 2.3 – Division en warp des blocs de dimension supérieure à un (warp de taille 2)

2.2 Codes CPU, GPU et kernel

Nous venons de voir comment lancer un kernel. L’étape suivante est de voir comment
coder un kernel. Dans le langage CUDA, il existe trois mots-clés spéciaux :
• __global__ : définit un kernel
• __device__ : définit une fonction exécutée par le device, et ne pouvant être appelée
que depuis le device
• __host__ : définit une fonction exécutée par l’host, et ne pouvant être appelée que
depuis l’host
Ces trois mots-clés sont placés devant le type de la fonction. Par exemple :
__global__ void MyKernel ( int * a ){
a [0] = 42;
}

Il est important de noter qu’un kernel est toujours de type void.


Une question légitime est de se demander quelle est la différence entre une fonction
__global__ et une fonction __device__. La réponse est simple : une fonction __global__
est appelée depuis le CPU et est exécutée sur le GPU. Une fonction __device__ est, quant à
elle, appelée 1 et exécutée sur le GPU.
Pour ce qui est des fonctions __device__, elles peuvent être de type quelconque, à l’inverse
des kernels.
Pour finir, notons qu’une fonction n’ayant aucun mot-clé est considérée comme une fonc-
tion __host__.
1. Appelée dans le cadre d’exécution d’une fonction __global__

15
2.3 Distinction entre les threads

Nous avons vu comment écrire un kernel, et comment ses threads seront organisés. Mainte-
nant, nous allons voir comment distinguer les threads, afin de les distribuer entre les données.
Un code kernel a à sa disposition un ensemble de variables spéciales, appelées built-in
variables. Celles-ci sont fixées, à l’appel, pour chaque thread. Ces built-in variables sont :
• gridDim : contient la taille de la grille
• blockIdx : contient l’identifiant du bloc, dans lequel est le thread courant
• blockDim : contient la taille du bloc, dans lequel est le thread courant
• threadIdx : contient l’identifiant, local au bloc, du thread courant
Notons que toutes ces variables sont des structures contenant les champs x, y et z. Ceux-
ci permettent de sélectionner la dimension recherchée. Prenons l’exemple de l’addition de
vecteurs de taille N :
Kernel d’addition vectorielle

__global__ void VectAdd ( float *a , float *b , float *c , int N ){


int i = blockIdx . x * blockDim . x + threadIdx . x ;

if ( i < N ) // a , b and c of size N


c [ i ] = a [ i ] + b [ i ];
}

Notons la présence d’une instruction de type if. Cette instruction peut, éventuellement,
amener à une divergence des threads du même warp, comme vu au chapitre précédent. Ce-
pendant, cette instruction est indispensable si plus de N threads ont été lancé. De plus, un
seul chemin d’exécution est non trivial. En effet, le chemin ne vérifiant pas la condition du
if se termine directement.

2.4 Transferts mémoires

Nous sommes maintenant aptes à écrire et à lancer des kernels CUDA. Mais, nous devons
encore programmer les échanges de données entre host et device.

2.4.1 Allocation de la mémoire device

Avant de copier des segments de mémoire, nous devons allouer l’espace device nécessaire.
La fonction prévue à cet effet est cudaMalloc. Cette fonction s’utilise assez naturellement.
Le code suivant, alloue un segment de mémoire de 42 flottants sur le device.
Allocation de mémoires device

float * vDevice ;

16
cudaMalloc (( void **) & vDevice , sizeof ( float ) * 42);

Après cette opération, le pointeur vDevice pointe vers le segment de mémoire alloué sur
le GPU.
La mémoire device allouée peut être libérée par la fonction cudaFree. Celle-ci s’utilise
exactement comme la fonction free.

2.4.2 Copies entre host et device

Copies synchrone

Maintenant que la mémoire device est allouée, nous pouvons y introduire des données. La
fonction chargée des transferts est cudaMemcpy. Cette fonction s’utilise comme suit :
cudaMemcpy ( void * DestinationPointer , const void * SourcePointer ,
size_t SizeInByteToCopy , enum cudaMemcpyKind KindOfCopy );

où KindOfCopy peut être :


• ’cudaMemcpyHostToDevice’, si le pointeur source est un pointeur vers le host, et si
le pointeur destination pointe vers le device
• ’cudaMemcpyDeviceToHost’, si le pointeur source est un pointeur vers le device, et
si le pointeur destination pointe vers l’host
Notons que KindOfCopy peut prendre d’autres valeurs.
Illustrons cette fonction par un exemple : chargeons un segment de mémoire de 17 flottants
de l’host vers le device. Ensuite, rechargeons ce segment vers l’host.
Transferts mémoires

float * vDevice , * vHost ;

// Allocate host memory //


vHost = ( float *) malloc ( sizeof ( float ) * 17);

// Allocate device memory //


cudaMalloc (( void **) & vDevice , sizeof ( float ) * 17);

// Host --- Device copy //


cudaMemcpy ( vDevice , vHost , sizeof ( float ) * 17 ,
c u d a M e m c p y H o s t T o D e v i c e );

// Device --- Host copy //


cudaMemcpy ( vHost , vDevice , sizeof ( float ) * 17 ,
c u d a M e m c p y D e v i c e T o H o s t );

Notons que la fonction cudaMemcpy retourne uniquement quand le transfert est terminé :
il s’agit d’un transfert synchrone.

17
Comme annoncé au chapitre 1, des mécanismes de copie asynchrone existent. Sans entrer
dans les détails, cette copie s’effectue au moyen de la fonction cudaMemcpyAsync. Dans ce cas,
le segment de mémoire host doit être page-locked, ce qui implique que le segment ne changera
pas d’adresse physique. Nous verrons plus tard comment allouer de tels segments.
Remarquons que l’utilisation de segments page-locked permet d’accélérer les transferts
mémoires en utilisant le mécanisme de DMA 2 . Cependant, le segment restant toujours en
mémoire physique, l’espace mémoire accessible diminue.

Mémoire page-locked et copie asynchrone

Afin d’obtenir un segment de mémoire page-locked, nous disposons de deux techniques.


La première méthode est l’utilisation de la fonction cudaMallocHost. Celle-ci permet
d’allouer un segment de mémoire host page-locked. Son utilisation est la suivante :
cudaMallocHost ( void ** ptr , size_t size );

où :
• ptr pointe vers le segment de mémoire host alloué
• size est la taille, en octet, à allouer
Pour ce qui est de la libération de la mémoire, celle-ci s’effectue par la fonction cudaFreeHost.
Celle-ci s’utilise exactement comme free.
Notons que cette première technique est affectée du problème suivant : le segment ne
pourra jamais être débloqué de la mémoire physique. Ainsi, si nous devons allouer un grand
nombre de segments, nous arriverons rapidement à court de mémoire.
Partant de ce constat, nous aimerions pouvoir bloquer un segment, le temps d’une copie,
puis le débloquer.
Nous en arrivons ainsi à notre deuxième méthode. Celle-ci exploite la fonction cudaHostRegister 3 .
Cette dernière s’utilise comme suit :
cudaHostRegister ( void * ptr , size_t size , unsigned int flags );

où :
• ptr pointe vers le segment de mémoire à bloquer
• size donne la taille du segment à bloquer
• flags permet de passer un ensemble de drapeaux, dont nous ne rentrerons pas dans
le détail
La fonction permettant de débloquer un segment est simplement cudaHostUnregister.
Celle-ci ne prend qu’un seul paramètre, le pointeur du segment bloqué.
Grâce à ces deux fonctions, nous pouvons donc bloquer et débloquer un segment en mé-
moire physique. Cependant, les segments mémoires utilisables par cudaHostRegister doivent
vérifier deux propriétés :
• L’adresse de départ du segment doit être un multiple de 4 KB
• La taille du segment doit être un multiple de 4 KB
2. DMA : Direct Memory Access — mécanisme déchargeant le processeur des transferts mémoires
3. Notons que cudaHostRegister n’existe qu’à partir de la version 4 de CUDA (voir [17])

18
On parle d’alignement, pour l’adresse et pour la taille, de 4 KB.
La contrainte sur l’adresse de départ peut être vérifiée, pour les systèmes UNIX, en allouant
la mémoire par la fonction posix_memalign. Celle-ci prend les arguments suivants :
posix_memalign ( void ** memptr , size_t alignment , size_t size );

tels que :
• après l’allocation, memptr pointe vers le segment
• alignement nous donne l’alignement sur l’adresse de départ
• size nous donne la taille du segment
Pour ce qui est de la contrainte sur la taille du segment, la solution est très simple. Si
le segment à bloquer n’est pas multiple de 4 KB, il suffit de bloquer un segment plus grand,
multiple de 4 KB.
Le code suivant illustre la deuxième méthode. Allouons un segment pointé par c et de
taille N, en octet, quelconque.
Exemple d’utilisation de cudaHostRegister

// Alloc aligned memory (4 K = 4096)//


posix_memalign (( void **)& c , 4096 , N );

// Lock page //
if ( N % 4096)
// We lock a bigger segment
cudaHostRegister (c , (( N / 4096) + 1) * 4096 , 0);
else
// We have a good segment
cudaHostRegister (c , N , 0);

// Unlock page //
cu da Ho st Un re gi st er ( c );

// Free memory //
free ( c );

Une question légitime est de savoir pourquoi les segments doivent être alignés sur 4 KB.
Il est assez compliqué de répondre à cette question, mais nous pouvons remarquer le fait
suivant : les systèmes d’exploitation modernes utilisent, par défaut, des tailles de page de
4 KB. Mais, il est important de rappeler, que cette taille de page peut être modifiée.

2.5 Exemple récapitulatif

Nous sommes maintenant capables d’écrire notre premier code CUDA complet. Prenons
comme exemple l’addition vectorielle.
Addition vectorielle : code complet

# include < stdlib .h >

19
# include < cuda .h > // CUDA Library

__global__ void VectAddKernel


( float *a , float *b , float *c , int N ){
int i = blockIdx . x * blockDim . x + threadIdx . x ; // Thread global ID
if ( i < N )
c [ i ] = a [ i ] + b [ i ]; // Each thread do a part of the job
}

void VectAdd ( float * aHost , float * bHost , float * cHost , int N ){


float * aDevice , * bDevice , * cDevice ;

// Allocate device memory //


cudaMalloc (( void **) & aDevice , sizeof ( float ) * N );
cudaMalloc (( void **) & bDevice , sizeof ( float ) * N );
cudaMalloc (( void **) & cDevice , sizeof ( float ) * N );

// Copy aHost and bHost on device //


cudaMemcpy ( aDevice , aHost , sizeof ( float ) * N ,
c u d a M e m c p y H o s t T o D e v i c e );
cudaMemcpy ( bDevice , bHost , sizeof ( float ) * N ,
c u d a M e m c p y H o s t T o D e v i c e );

// Launch kernel //
dim3 TpB (256);
dim3 BpG (( N + TpB . x - 1) / TpB . x );
VectAddKernel < < < BpG , TpB > > >( aDevice , bDevice , cDevice , N );

// Get result form host //


cudaMemcpy ( cHost , cDevice , sizeof ( float ) * N ,
c u d a M e m c p y D e v i c e T o H o s t );

// Free device memory //


cudaFree ( aDevice );
cudaFree ( bDevice );
cudaFree ( cDevice );
}

int main ( void ){


int N = 42; // Vector size
float *a , *b , * c ;

// Allocate host memory //


a = ( float *) malloc ( sizeof ( float ) * N );
b = ( float *) malloc ( sizeof ( float ) * N );
c = ( float *) malloc ( sizeof ( float ) * N );

// Compute Addition ( of noise vectors ) //


VectAdd (a , b , c , N );

// Now , we have c = a + b //

20
// Free host memory //
free ( a );
free ( b );
free ( c );

return 0;
}

En anticipant sur la section 2.9, signalons que la compilation de ce code (baptisons la


source add.cu 4 ) est réalisée par la commande :
nvcc add . cu

Pour finir cette section, remarquons que le header des librairies CUDA est cuda.h.

2.6 CUDA Streams

2.6.1 Généralités

Comme nous l’avons vu précédemment, certaines cartes NVIDIA permettent de réaliser du


parallélisme de tâche. Plus précisément, ces cartes sont capables de gérer simultanément des
transferts mémoires et des opérations de calcul. Dans le jargon CUDA, on parle de concurrent
copy and execution.
Les architectures pouvant exploiter ce parallélisme disposent de copy engines. Une archi-
tecture avec :
• Un copy engine peut gérer en parallèle 5 un transfert mémoire, soit montant, soit
descendant
• Deux copy engines peut gérer en parallèle deux transferts mémoires, un montant et
un descendant
Ce mécanisme de copy engine permet, dans certains cas, une forte accélération du code, en
exploitant le mécanisme de pipeline. Nous entendons par pipeline, le fait de pouvoir charger
des données, tout en continuant l’exécution d’un kernel. Illustrons ce principe à la figure 2.4.
Pour en terminer avec les généralités, notons que le gain du pipeline, a un prix en termes
de complexité de programmation, comme nous le verrons ci-après.

4. Les sources CUDA ont l’extension .cu


5. Comprendre en plus du kernel

21
Gestion du temps sans pipeline
Copie montante :

Exécution du Kernel :

Copie descendante :

Temps

Gestion du temps avec pipeline


Copie montante :

Exécution du Kernel :

Copie descendante :

Temps

Figure 2.4 – Illustration du principe de pipeline (cas de deux copy engines)

2.6.2 CUDA Streams

Techniquement parlant, ce mécanisme de pipeline s’implémente via des CUDA Streams.


Ce mécanisme nous permet d’obtenir une garantie sur l’ordre des opérations. Ainsi, nous
pourrons être certains du fait que :
• les copies devices 6 seront terminées avant le lancement du kernel
• le kernel aura terminé son exécution avant les copies hosts 7
Notons que, sans surprises, les transferts mémoires devront être asynchrones, pour pou-
voir utiliser le mécanisme de pipeline. En effet, il est impossible d’obtenir la configuration
temporelle de la figure 2.4, si nous devons attendre la fin de chaque transfert mémoire.
Au niveau du langage CUDA, la création d’un stream est le résultat de la commande sui-
vante.
Création d’un CUDA Stream

// Stream declaration
cudaStream_t Stream ;

// Stream creation
cudaStreamCreate (& Stream );

Pour ce qui est de la destruction d’un stream, nous utiliserons la fonction :


cud aStrea mDestr oy ( Stream );

où Stream est le stream à détruire.


6. Comprendre de l’host vers le device
7. Comprendre du device vers l’host

22
L’attachement d’une opération de copie (asynchrone) à un stream est le résultat de la
commande suivante :
cudaMemcpyAsync ( void * DestinationPointer , const void * SourcePointer ,
size_t SizeInByteToCopy ,
enum cudaMemcpyKind KindOfCopy , cudaStream_t Stream );

Notons que cette fonction prend les mêmes arguments que cudaMemcpy, ainsi qu’un stream
CUDA.
Maintenant que nous savons comment attacher une copie à un stream, il ne nous reste plus
qu’à montrer comment attacher un kernel à un stream. Cette opération se fait au lancement
du kernel :
MyKernel < < < BpG , TpB , DS , Stream > > >(...);

Nous retrouvons l’appel classique d’un kernel, avec deux arguments supplémentaires :
• DS — il s’agit de la quantité de mémoire partagée (en octet) allouée dynamiquement
par bloc 8
• Stream — il s’agit simplement du stream sur lequel le kernel sera attaché
Par défaut, ces arguments sont mis à zéro, tout en respectant le type du paramètre.

2.6.3 Synchronisation des tâches

Comme nous l’avons vu, l’organisation de plusieurs tâches en pipeline demande des appels
asynchrones. Cependant, il nous faut un mécanisme permettant au CPU d’attendre la fin du
pipeline.
Dans l’approche sans pipeline, cette synchronisation était assurée par la copie 9 , synchrone,
descendante 10 . Notons que, implicitement, les copies et le kernel sont dans le même stream.
Ainsi, la copie descendante attendra la fin du kernel. Et, comme la copie est synchrone, le
CPU attendra la fin de celle-ci. En conclusion, sans pipeline nous avons une synchronisation
implicite sur la copie descendante.
Pour la structure en pipeline, nous n’avons aucun mécanisme implicite de synchronisa-
tion. Nous devrons donc exploiter un mécanisme explicite. A cette fin, nous utiliserons les
CUDA Events. Ceux-ci peuvent être attachés à des streams, et disposent d’une opération de
synchronisation. Cette opération bloquera le CPU, tant que l’event n’a pas été exécuté par le
stream.
Notons que ce mécanisme de synchronisation peut être global. Dans ce cas, il faut attacher
l’event au stream 0. Ce stream désigne le contexte CUDA. Sans entrer dans les détails, un
contexte peut être vu comme un processus, au sens classique sur CPU. Ainsi, un contexte
englobe tous les streams.
8. Cette quantité de mémoire s’ajoutera alors à la mémoire allouée statiquement
9. Rappelons qu’un appel kernel est toujours asynchrone — Nous ne pouvons donc pas utiliser le kernel
pour la synchronisation
10. Nous entendons par copie descendante, une copie du device vers l’host — De façon symétrique, une copie
montante va de l’host vers le device

23
Plus précisément, un event est dit terminé, lorsque toutes les commandes qui le précèdent
dans un stream ont été accomplies. Si jamais l’event est attaché au stream O, l’event est
terminé dès que toutes les commandes, de tous les streams, ont été accomplies.
Au niveau du langage CUDA, la création d’un event est prise en charge par la fonc-
tion cudaEventCreate. Celle-ci prend un pointeur vers une variable de type cudaEvent_t.
L’exemple suivant illustre la création d’un event.
Création d’un event

cudaEvent_t e ; // Event declaration

cudaEventCreate (& e ); // Event creation

La destruction de l’event est assurée par cudaEventDestroy. Cette fonction prend en


argument l’event à détruire.
L’attachement d’un event à un stream s’effectue par l’opération cudaEventRecord. Notons
qu’en plus de lier l’event à stream, cette fonction fixe la position de l’event. Celle-ci est la
même que la position de la fonction. Cette dernière prend les arguments suivants :
cudaEventRecord ( cudaEvent_t event , cudaStream_t stream );

où :
• event est l’event à attacher
• stream est le stream sur lequel l’event sera attaché
Notons que si stream est mis à 0, l’event sera attaché au contexte CUDA.
Maintenant, il ne nous reste plus qu’à synchroniser le CPU sur l’event. Ainsi, dès que
cudaEventRecord aura été exécuté par le stream, ou par le contexte, le processus host
pourra continuer d’avancer dans son code. La fonction permettant cette synchronisation est
cudaEventSynchronize. Celle-ci prend en argument l’event à attendre.

2.6.4 Le parallélisme de tâche

Maintenant que nous sommes capables d’attacher et de synchroniser des copie asynchrones
et des kernels à des streams, nous pouvons exploiter le mécanisme vu au début de cette section.
Pour rappel, les streams nous permettent d’avoir une garantie sur l’ordre d’exécution des
opérations. Les events, quant à eux, nous permettent de nous synchroniser.
Prenons comme exemple l’exécution de deux kernels, chacun demandant une copie mon-
tante et une descendante. Nous aimerions organiser les copies et les kernels, afin d’obtenir une
structure en pipeline. Le code suivant est parfaitement correct. Nous ignorerons les étapes
d’allocation de mémoire, afin de nous concentrer sur le mécanisme de pipeline.
Première tentative de pipeline

// Create Streams //
cudaStream_t streamA , streamB ;
cudaStreamCreate (& streamA );
cudaStreamCreate (& streamB );

24
// Create Event //
cudaEvent_t sync ;
cudaEventCreate (& sync );

// Upload -- Kernel -- Download -- Stream A //


cudaMemcopy ( pDeviceA , pHostA , sizeInByteA ,
cudaMemcpyHostToDevice , streamA );

kernelA < < < BpG , TpB , 0 , streamA > > >( pDeviceA );

cudaMemcopy ( pHostA , pDeviceA , sizeInByteA ,


cudaMemcpyDeviceToHost , streamA );

// Upload -- Kernel -- Download -- Stream B //


cudaMemcopy ( pDeviceB , pHostB , sizeInByteB ,
cudaMemcpyHostToDevice , streamB );

kernelB < < < BpG , TpB , 0 , streamB > > >( pDeviceB );

cudaMemcopy ( pHostB , pDeviceB , sizeInByteB ,


cudaMemcpyDeviceToHost , streamB );

// Synchronize on CUDA Context //


cudaEventRecord ( sync , 0);
c u d a E v e n t S y n c h r o n i z e ( sync );

// Here , we have pHostA and pHostB processed //

// Destroy Event //
cudaEventDestroy ( sync );

// Destroy Streams //
cud aStrea mDestr oy ( streamA );
cud aStrea mDestr oy ( streamB );

2.6.5 Contrainte hardware

Bien que parfaitement licite, le code précédent n’exploitera pas la configuration en pipeline.
La raison provient de la gestion hardware des streams.
Pour le hardware, les streams n’existent pas, seuls les engines 11 existent ! Ainsi, le hard-
ware place les tâches sur les engines, indépendamment de leurs streams d’origine, et dans
l’ordre des appels. Mais, afin de garantir l’ordre d’exécution, le hardware peut bloquer une
tâche mise sur un engine. De plus, les tâches suivantes seront mises en attente. Ainsi, le
pipeline est rompu.
11. Nous entendons par engine, soit un copy engine, soit un engine d’exécution kernel

25
Dans le cas où nous appelons, d’abord toutes les tâches du premier stream, puis toutes les
tâches du second stream, nous nous retrouvons avec nos engines dans la configuration de la
figure 2.5. Pour des questions de simplicité, nous supposerons un seul copy engine.
Copy Engine Kernel Engine

Copie Device (Stream A) n te Kernel (Stream A)


Atte
Copie Host (Stream A) Kernel (Stream B)
Copie Device (Stream B)
Bloquage
Copie Host (Stream B)

Figure 2.5 – État des engines pour la première tentative de pipeline (d’après [20])

Comme nous l’avons signalé, afin de respecter l’ordre des opérations, un engine peut
suspendre une tâche. Ainsi, partant de la figure 2.5, nous pouvons constater les faits suivants :
1. La copie vers l’host, pour le stream A, doit attendre la fin du kernel A.
2. La copie vers le device, pour le stream B, vient après la copie vers l’host, pour le
stream A.
3. Ainsi, la copie vers le device, pour le stream B, est forcée d’attendre la fin du kernel A.
4. On ne peut donc pas, en même temps, copier et exécuter un kernel.
Donc, finalement, le pipeline est rompu.
Une organisation des engines conduisant à un pipeline est disponible à la figure 2.6.
Copy Engine Kernel Engine

Copie Device (Stream A) line Kernel (Stream A)


Pipe
Copie Device (Stream B) Kernel (Stream B)
Copie Host (Stream A)
Copie Host (Stream B)

Figure 2.6 – Organisation des engines conduisant à un pipeline (d’après [20])

Dans ce nouveau cas, la copie vers le device, pour le stream B, peut avoir lieu en même
temps que l’exécution du kernel A. Ainsi, nous nous retrouvons dans une situation de pipeline.
Notons qu’il est toujours possible que les copies descendantes, dans l’attentes de leurs
résultats, bloquent le pipeline. Ceci peut être en partie évité, si le nombre de kernel à exécuter
est grand. Ainsi, seules les dernières copies bloqueront le pipeline.
Au niveau de l’implémentation, il suffit simplement de lancer d’abord toutes les copies
montantes, puis tous les kernels, puis toutes les copies descendantes. Le code suivant reprend
ce qui vient d’être expliqué.
Implémentation conduisant à un pipeline

// Create Streams //
cudaStream_t streamA , streamB ;
cudaStreamCreate (& streamA );
cudaStreamCreate (& streamB );

26
// Create Event //
cudaEvent_t sync ;
cudaEventCreate (& sync );

// Uploads //
cudaMemcopy ( pDeviceA , pHostA , sizeInByteA ,
cudaMemcpyHostToDevice , streamA );

cudaMemcopy ( pDeviceB , pHostB , sizeInByteB ,


cudaMemcpyHostToDevice , streamB );

// Kernels //
kernelA < < < BpG , TpB , 0 , streamA > > >( pDeviceA );

kernelB < < < BpG , TpB , 0 , streamB > > >( pDeviceB );

// Downloads //
cudaMemcopy ( pHostA , pDeviceA , sizeInByteA ,
cudaMemcpyDeviceToHost , streamA );

cudaMemcopy ( pHostB , pDeviceB , sizeInByteB ,


cudaMemcpyDeviceToHost , streamB );

// Synchronize on CUDA Context //


cudaEventRecord ( sync , 0);
c u d a E v e n t S y n c h r o n i z e ( sync );

// Here , we have pHostA and pHostB processed //

// Destroy Event //
cudaEventDestroy ( sync );

// Destroy Streams //
cud aStrea mDestr oy ( streamA );
cud aStrea mDestr oy ( streamB );

2.7 Le multi–GPU

La gestion de plusieurs GPUs pose le problème suivant : les devices doivent être commandés
par le host, mais le host ne peut commander qu’un seul device.
Pour remédier à ce problème, la solution consiste à multithreader l’host : chaque thread
de l’host s’occupera alors de la gestion d’un GPU. Dans le cadre ce travail, l’API utilisée est
OpenMP 12 .
12. Toutes les spécifications de OpenMP sont disponibles dans [18]

27
Du point de vue de la programmation, pour qu’un thread host s’attache à un device,
celui-ci doit exécuter la fonction cudaSetDevice(deviceId). La variable deviceId est un
entier désignant le device. Notons que les devices sont numérotés en partant de zéro.
Remarquons qu’il existe des fonctions permettant de renvoyer tous les devices compatibles
CUDA, leur identifiant et leurs propriétés. Pour plus d’informations, nous renvoyons le lecteur
à [14].

2.8 Synchronisation et opérations atomiques

Remarquons tout d’abord que les architectures de GPUs disposent d’opérations atomiques.
Afin d’obtenir des codes de calculs généraux et parallèles, il est indispensable de disposer
de mécanismes de synchronisation. Il existe un mécanisme de barrière 13 pour les threads d’un
même bloc : il s’agit de la fonction __syncthreads().
Si la synchronisation doit être effectuée à un niveau supérieur au bloc (entre deux blocs
par exemple), celle-ci devra être codée explicitement. Dans ce but, nous pouvons exploiter les
opérations atomiques du GPU et la mémoire globale. Étant donné le passage par la mémoire
globale, un tel mécanisme sera extrêmement lent.

2.9 Compilation

Au niveau de la compilation, NVIDIA a publié son propre compilateur CUDA, appelé nvcc.
Ce compilateur commence par décomposer le code en sa partie device et sa partie host. La
partie device sera compilée par nvcc lui-même, tandis que la partie host sera compilée grâce
à:
• The GNU compiler pour les plateformes Linux
• The Microsoft Visual Studio compiler pour les plateformes Windows
Notons qu’il est possible de changer le compilateur host de nvcc.
Afin de gérer la compilation de projets très vastes, des méthodes de compilation de plus
haut niveau existent. Dans le cadre de ce travail, nous utiliserons l’outil de développement
CMake 14 . Celui-ci dispose d’un module gérant la compilation des codes hosts et devices. Ce
module, du nom de FindCUDA 15 , est disponible nativement dans la version 2.8 de CMake.
Remarquons que les fonctions compilées par nvcc seront affectées par du name mangling 16 .
Ce processus peut conduire à des incompréhensions entre nvcc et le compilateur host. Afin
de résoudre ce problème, les fonctions vues par les deux compilateurs devront être marquées
comme extern "C".
13. Tous les threads du même bloc doivent exécuter la barrière, avant de pouvoir continuer dans leurs codes
14. Toutes les informations sur CMake sont disponibles sur la page : http://www.cmake.org
15. Les spécifications de FindCUDA sont disponibles sur le site de CMake
16. Dans certains cas, en fonction du langage, le compilateur peut ajouter des éléments aux noms des
fonctions, des variables, etc — Cette opération s’appelle le name mangling

28
2.10 Mécanisme de programmation de plus bas niveau

Jusqu’à présent, nous avons discuté du langage CUDA C 17 . Cependant, il ne s’agit pas du
seul mécanisme de programmation proposé par NVIDIA.
Pour ce qui est du contrôle du GPU par le CPU, celui-ci peut être beaucoup plus explicite
en passant par le Driver API. A ce niveau, le GPU doit être géré beaucoup plus explicite-
ment. Cette gestion est automatisée dans la version haut niveau présentée dans les sections
précédentes.
Par exemple, via le Driver API, le lancement d’un kernel n’est pas simplement :
VectAddKernel < < < BpG , TpB > > >( aDevice , bDevice , cDevice , N );

Ce lancement serait de la forme :


Version simplifiée d’un lancement de kernel par le Driver API

// Set Kernel Arguments //


cuParamSetv ( VectAddKernel , & aDevice );
cuParamSetv ( VectAddKernel , & bDevice );
cuParamSetv ( VectAddKernel , & cDevice );
cuParamSetv ( VectAddKernel , & N );

// Set 1 D Blocks //
c uF u n c Se t B lo c k Sh a p e ( VectAddKernel , TpB , 1 , 1);

// Launch Kernel in a 1 D Grid //


cuLaunchGrid ( VectAddKernel , BpG , 1);

Notons que cette façon de procéder est reprise par le langage OpenCL.
Au niveau de la programmation du GPU, il exsite également un langage d’assembleur pour
les machines NVIDIA. Ce langage porte le nom de PTX 18 . Notons que le compilateur nvcc est
également capable d’assembler un code PTX. Il est aussi possible de demander au compilateur
de ne générer que le code assembleur, via la commande :
nvcc - ptx mysource . cu

Pour terminer, notons que toutes les spécifications de PTX sont disponibles dans [13].

17. Ou, plus simplement, le langage CUDA


18. PTX : Parallel Thread Execution

29
Chapitre 3

Le langage OpenCL

Le langage OpenCL est un langage de programmation pour GPU se voulant multiplateforme,


standard et ouvert.
A l’origine, ce langage a été développé par Apple pour être ensuite repris par le Khronos
Group, connu pour le standard OpenGL.
Bien que la majeure partie du présent travail soit basée sur CUDA, il nous paraissait indispensable
d’aborder le sujet d’OpenCL, ne serait-ce que pour ses aspects multiplateformes. En effet, il serait
dommage de se limiter aux seules plateformes NVIDIA.
Ce chapitre est basé sur les références [5, 16, 4].

3.1 Introduction

Philosophiquement, l’approche OpenCL est assez proche de l’approche CUDA. Cependant,


notons deux grandes différences.
La première différence est que, comme nous allons le voir par la suite, les codes OpenCL
sont beaucoup plus verbeux que les codes CUDA C 1 . Ceci s’explique par le coté multiplateforme
d’OpenCL.
La seconde différence est la suivante : le code device est compilé à l’exécution du code
host. Le code host est, quant à lui, compilé par un compilateur C/C++ quelconque. Notons
que le header des fonctions OpenCL est contenu dans CL/cl.h.
L’argument avancé pour la compilation au runtime est qu’ainsi le code dispose toujours
des dernières avancées en termes de compilation. Le revers de la médaille se situe dans un
overhead au lancement du code. Cependant, si le problème traité est suffisamment grand,
l’overhead est négligeable face au temps de calcul.
1. En réalité, les codes OpenCL sont aussi verbeux que les codes basés sur le Driver API de NVIDIA

30
3.2 Vocabulaire

Dans l’approche OpenCL certains concepts CUDA sont repris, mais avec des appellations
différentes. Dans un souci de concision, nous introduirons à la table 3.1 les concepts importants
communs entre OpenCL et CUDA.

CUDA OpenCL
Thread Work item
Bloc Work group
Grille NDRange
Mémoire partagée Local memory
Mémoire registre Private memory

Table 3.1 – Comparaison entre les termes CUDA et OpenCL (d’après [5])

Du point de vue matériel, OpenCL étant multiplateforme, certains termes hardware ont
également été renommés. Ces changements de désignation sont repris à la table 3.2.

CUDA OpenCL
Streaming processor (SP) Processing element (PE)
Streaming multiprocessor (SM) Compute unit (CU)

Table 3.2 – Termes OpenCL désignant le matériel (d’après [5])

Pour terminer cette section sur le vocabulaire, dans les spécifications d’OpenCL un nouveau
concept apparait : il s’agit de la plateforme. On entend par plateforme l’ensemble constitué
d’un host et de un, ou plusieurs, devices. Ce mécanisme permet la gestion de plusieurs GPUs.

3.3 Code kernel

Les codes kernels OpenCL sont fort similaires à leur homologue CUDA. Les différences les
plus importantes sont les suivantes :
• le code kernel est précédé du mot-clé __kernel, au lieu de __global__
• les arguments du code kernel ne peuvent pas être des structures
• les arguments du code kernel, résidant en mémoire (device) globale, doivent être
précédés du mot-clé __global
• il n’y a pas de build-in variables, mais des fonctions permettant d’obtenir les différents
identifiants
Les fonctions permettant de distinguer les working items sont reprises à la tables 3.3.

31
Fonction OpenCL Description Équivalent CUDA
get_global_id Identifiant global blockIdx * blockDim + threadIdx
du working item
get_local_id Identifiant local threadIdx
du working item
get_global_size Taille du NDRange gridDim * blockDim
get_local_size Taille du working group blockDim

Table 3.3 – Fonctions identifiant les working items (d’après [5])

Ces fonctions prennent comme argument un entier, celui-ci étant la dimension considérée.
Notons que la numérotation commence à zéro. Par exemple, get_local_id(1) correspond à
threadIdx.y, c’est-à-dire à la dimension 2 de l’identifiant local du working item.

3.4 Code host

Nous présenterons dans cette section les différentes étapes pour écrire un code host.

3.4.1 Contexte OpenCL

Nativement, OpenCL est capable de gérer plusieurs devices 2 . La notion de contexte permet
de traiter ce problème. Un contexte désigne, entres autres, un ensemble de devices. Dans le
langage OpenCL, la fonction clCreateContext permet de retourner un contexte 3 .
cl_context clCreateContext
( const c l _ c o n t e x t _ p r o p e r t i e s * properties ,
cl_uint num_devices ,
const cl_device_id * devices ,
void ( CL_CALLBACK * pfn_notify )
( const char * errinfo ,
const void * private_info , size_t cb ,
void * user_data ) ,
void * user_data ,
cl_int * errcode_ret )

où :
• *properties, permet de spécifier la plateforme à utiliser (si ce pointeur est NULL, la
plateforme sélectionnée est définie par l’implémentation d’OpenCL)
• num_device, donne le nombre de devices dans *devices
• *devices, pointe vers la liste de devices à utiliser dans le contexte
• *errcode_ret, pointe vers une variable, où le code d’erreur sera retourné
• *pfn_notify, pointe vers une fonction à appeler en cas d’erreur
• Les autres paramètres sont les arguments de la fonction pointée par *pfn_notify
2. Rappelons nous qu’avec CUDA, nous devions multithreader le code host, afin que chaque thread s’occupe
d’un device
3. Un contexte est de type cl_context

32
Afin d’obtenir la liste des devices, nous utiliserons les fonctions clGetPlatformIDs puis
clGetDeviceIDs. Ces fonctions s’utilisant assez naturellement, nous ne les spécifierons pas
dans ce travail 4 . Notons simplement que ces deux fonctions seront toujours appelées deux
fois : une fois pour obtenir le nombre de plateformes (ou de devices), et une fois pour pour
obtenir la liste des plateformes (ou des devices).

3.4.2 File de commandes

La seconde étape pour écrire un code OpenCL est de déclarer une file de commande ou
Command Queue. Cette file 5 permettra à l’host de charger les instructions kernel.
La fonction retournant une command queue est clCreateCommandQueue. Une fois encore,
cette fonction s’utilisant assez naturellement, elle ne sera pas spécifiée.
Pour terminer, notons qu’en cas d’utilisation de plusieurs devices, il est nécessaire de créer
une file de commande par device 6 .

3.4.3 Compilation du kernel

Pour commencer, notons que le code kernel est vu depuis l’host comme un simple tableau
de caractères.

Création d’un objet programme

La première étape pour compiler le kernel est de créer un objet programme. Pour ce faire,
nous pouvons utiliser la fonction clCreateProgramWithSource.
cl_program c l C r e a t e P r o g r a m W i t h S o u r c e ( cl_context context ,
cl_uint count ,
const char ** strings ,
const size_t * lengths ,
cl_int * errcode_ret );

où :
• context, est le contexte
• count, donne le nombre de tableaux de caractères contenus dans strings
• **strings, pointe vers un tableau, de tableaux de caractères, contenant les caractères
du code kernel
• *lengths, pointe vers un tableau contenant la taille des tableaux de strings
• *errcode_ret, pointe vers le code retourné en cas d’erreur
Il est très important de noter que, même si length est connu à la compilation, celui-ci
doit être alloué par malloc.
4. Notons que les spécifications de toutes les fonctions OpenCL sont disponibles dans [4]
5. Notons que cette file n’est pas forcément de type FIFO (Fist In First Out) : en effet, l’exécution des
instructions kernel peut être en mode out-of-order
6. L’identifiant du device est l’un des arguments de clCreateCommandQueue

33
Notons qu’avec OpenCL, il n’y a pas explicitement de fonctions devices, comme avec les
fonctions CUDA __device__. Si nous voulons déclarer une telle fonction, il suffit simplement
de mettre son code dans le tableau de caractères contenant le code kernel.

Compilation du kernel

La fonction compilant le kernel est clBuildProgram.


cl_int clBuildProgram
( cl_program program ,
cl_uint num_devices ,
const cl_device_id * device_list ,
const char * options ,
void ( CL_CALLBACK * pfn_notify )
( cl_program program ,
void * user_data ) ,
void * user_data )

où :
• program, est l’objet programme
• num_device, est le nombre de device contenu dans la liste device_list
• *device_list, pointe la liste des devices sur lesquels le kernel sera exécuté
• *options, pointe vers une liste d’options à passer au compilateur (voir [4])
• Le reste des arguments concerne la fonction appelée en cas d’erreur

Création d’un objet kernel

Pour terminer la compilation, il ne reste plus qu’à définir un objet kernel. Cet objet
pourra être utilisé pour appeler le kernel. La fonction chargée de retourner le kernel est
clCreateKernel. Cette fonction s’utilise assez naturellement, et ne sera pas spécifiée dans ce
travail.

3.4.4 Allocation et transferts mémoires

L’allocation de la mémoire device s’effectue via la fonction clCreateBuffer. Cette fonc-


tion renvoie un objet mémoire lié au segment device alloué.
cl_mem clCreateBuffer ( cl_context context ,
cl_mem_flags flags ,
size_t size ,
void * host_ptr ,
cl_int * errcode_ret )

où :
• context, est le contexte
• flags, permet de modifier l’allocation, et l’utilisation, d’un segment de mémoire
• *host_ptr, permet de copier un segment, directement à l’allocation, de l’host vers le
device (nécessite l’utilisation d’un flag)
• *errcode, pointe vers le code retourné en cas d’erreur

34
Parmi les valeurs de flags, citons CL_MEM_COPY_HOST_PTR permettant de copier le segment
pointé par *host_ptr directement à l’allocation. Notons qu’une fois le segment copié, les
segments host et device sont indépendants.
Pour ce qui est de la lecture d’un segment de mémoire device, nous devons passer par la
fonction clEnqueueReadBuffer.
cl_int c lE n q ue u e Re a d Bu f f er ( cl_command_queue command_queue ,
cl_mem buffer ,
cl_bool blocking_read ,
size_t offset ,
size_t cb ,
void * ptr ,
cl_uint num_events_in_wait_list ,
const cl_event * event_wait_list ,
cl_event * event )

où :
• command_queue, est la file de commandes
• buffer, est l’objet mémoire lié au segment device à lire
• blocking_read, spécifie si la lecture est synchrone (CL_TRUE), ou non (CL_FALSE)
• offset, donne un offset du segment à lire
• cb, donne le nombre d’octets à lire
• *ptr, pointe vers le segment de mémoire host, où les données device seront copiées
• num_events_in_wait_list, donne le nombre d’éléments dans event_wait_list
• *event_wait_list, pointe vers une liste d’évènements à vérifier avant d’effectuer la
lecture.
• *event, pointe vers un évènement produit par clEnqueueReadBuffer pour identifier
l’opération de lecture
Notons qu’en plus du flag d’écriture de clCreateBuffer, il existe un mécanisme d’écriture
en mémoire device similaire à clEnqueueReadBuffer. Il s’agit simplement de la fonction
clEnqueueWriteBuffer. Cette fonction s’utilise d’une manière analogue à son homologue de
lecture.

3.4.5 Appel du kernel

Avant d’appeler le kernel, il importe de lui passer ses arguments : cette étape est réalisée
par la fonction clSetKernelArg. Notons que cette fonction s’utilise assez naturellement, et
ne sera donc pas spécifiée dans ce travail.
La fonction clEnqueueNDRangeKernel permet l’appel proprement dit du kernel.
c l E n q u e u e N D R a n g e K e r n e l ( cl_command_queue command_queue ,
cl_kernel kernel ,
cl_uint work_dim ,
const size_t * global_work_offset ,
const size_t * global_work_size ,
const size_t * local_work_size ,
cl_uint num_events_in_wait_list ,
const cl_event * event_wait_list ,
cl_event * event )

35
où :
• command_queue, est la file de commandes, où le kernel sera lancé
• kernel, est le kernel à lancer
• work_dim, donne la dimension du NDRange et des work groups
• *global_work_offset, pointe vers un segment donnant un offset pour l’identifiant
global des work items (l’élément 0 donne l’offset pour la dimension 1, l’offset 1 pour
la dimension 2, etc)
• *global_work_size, donne la division du NDRange en work groups (équivalent de
NumberOfBlocks sous CUDA)
• *local_work_size, donne la division des work groups en work items (équivalent de
ThreadsPerBlock sous CUDA)
• Les autres arguments gèrent les évènements (voir clEnqueueReadBuffer)

3.4.6 Libération des ressources

Tout au long de notre code, nous avons alloué un ensemble d’objets. Afin d’avoir une
présentation complète, la table 3.4 reprend les fonctions utiles pour libérer les ressources.

Fonction Objet libéré


clReleaseMemObject Mémoire
clReleaseKernel Kernel
clReleaseProgram Programme
clReleaseCommandQueue File de commandes
clReleaseContext Contexte

Table 3.4 – Fonctions pour libérer les ressources

3.5 Un exemple complet

Un exemple complet de multiplication matricielle est disponible à l’adresse :


http://www.student.montefiore.ulg.ac.be/~marsic.
Sous Linux, ce code se compile via la commande :
gcc - lOpenCL - lm clmmult . c

où :
• -lOpenCL, permet de lier notre code avec les librairies OpenCL
• -lm, permet à notre code d’utiliser la fonction ceil (arrondit un réel à l’entier supé-
rieur)

36
Chapitre 4

Étude des performances

Maintenant que nous sommes familiers avec les architectures de GPUs et avec leurs langages
de programmation, nous pouvons commencer à comparer différents algorithmes de calculs.
Dans ce chapitre, nous étudierons différentes implémentations de la multiplication matricielle,
de la multiplication matrice–vecteur, et de la multiplication vecteur–vecteur.

4.1 Notions complémentaires

4.1.1 Librairies BLAS

Il existe un ensemble de librairies optimisées pour l’algèbre linéaire. Ces librairies portent
le nom générique de BLAS, pour Basic Linear Algebra Subprograms.
ATLAS, pour Automatically Tuned Linear Algebra Software, est une des implémentations
de BLAS. A la compilation, celle-ci teste différents algorithmes, afin de ne retenir que les plus
performants pour la plateforme donnée.
Dans le monde du GPU, NVIDIA a développé sa propre version de BLAS, appelée CUBLAS,
optimisée pour ses plateformes de calcul.
Notons qu’il n’existe pas, à l’heure actuelle, d’équivalent OpenCL à CUBLAS.

4.1.2 Cartes NVIDIA Tesla

Les cartes Tesla sont une déclinaison de la gamme des cartes graphiques de NVIDIA, mais
uniquement dédiées aux calculs. Par exemple, ces cartes ne disposent pas de sorties vidéos.

4.2 Matériel utilisé

Les tests de ce travail ont été réalisés sur quatre machines différentes :
• desktop
• fermi

37
• lmgpu
• gameboy

4.2.1 Plateforme desktop

Hardware

La plateforme desktop est un ordinateur classique, dédié aux jeux vidéo et équipé d’un
processeur Intel Core 2 Duo E6750 et d’une carte graphique NVIDIA GeForce 8500 GT.
Le processeur est un dual-core, cadencé à 2.66 GHz et considéré comme étant d’entrée de
gamme en 2007.
La carte graphique possède, quant à elle, 16 streaming processors cadencés à 450 MHz et
était également considérée comme d’entrée de gamme en 2007.
Au niveau de la mémoire, l’host dispose de 2 GB, tandis que le device n’en possède que
512 MB.

Figure 4.1 – Photographie d’une carte graphique NVIDIA 8500 GT

Pour terminer, notons que toutes les spécifications sont disponibles sur les pages internet
suivantes :
• E6750 : http://ark.intel.com/Product.aspx?id=30784
• 8500 GT : http://www.nvidia.com/object/geforce_8500.html

Software

Les différents logiciels disponibles sur la plateforme desktop sont repris à la tables 4.1.
Le CUDA Toolkit est la suite logicielle proposée par NVIDIA, comprenant le compilateur
nvcc, les librairies CUDA et CUBLAS, un debugger (cuda-gdb), etc.

38
Logiciel Version
Noyau linux 2.6.32 (32 bits)
Drivers NVIDIA 195.36.24
gcc 4.4.4
CUDA Toolkit 3.0
ATLAS 3.8

Table 4.1 – Logiciels disponibles sur desktop

4.2.2 Plateforme fermi

Hardware

La plateforme fermi est l’évolution de la plateforme desktop. En effet, au cours de ce


travail nous avons acquis une carte NVIDIA GeForce GT430. Celle-ci a remplacé la 8500 GT.
La GT430 est une carte entrée de gamme, basée sur l’architecture Fermi. Elle dispose de
96 SPs cadencés à 1.40 GHz. Au niveau de la mémoire, nous disposons ici de 1 GB.
Notons que la nouvelle carte dispose d’une interface PCI Express 2.0. Cependant, notre
carte mère ne prend en charge que la version 1.0. Notre carte est donc bridée.

Figure 4.2 – Photographie d’une carte graphique NVIDIA GT430

Pour ce qui est de la partie host, aucune modification n’a été apportée, en passant de la
plateforme desktop à la plateforme fermi.
Toutes les spécifications de la carte NVIDIA GeForce GT430 sont disponibles à l’adresse :
http://www.nvidia.com/object/product-geforce-gt-430-us.html.

39
Software

Les différents logiciels disponibles sur la plateforme fermi sont repris à la tables 4.2.
Signalons le passage à la version 4.0 RC2 du CUDA Toolkit.

Logiciel Version
Noyau linux 2.6.32 (32 bits)
Drivers NVIDIA 270.40
gcc 4.4.4
CUDA Toolkit 4.0 RC2
ATLAS 3.8

Table 4.2 – Logiciels disponibles sur fermi

4.2.3 Plateforme lmgpu

Hardware

La plateforme lmgpu est une station dédiée aux calculs intensifs sur GPU. Celle-ci est située
au CISM 1 de l’Université Catholique de Louvain.
Du point de vue matériel, cette machine est équipée d’un processeur Intel Xeon X5550 et
de deux cartes NVIDIA Tesla C1060.
Au niveau du CPU, celui-ci dispose de 4 cœurs cadencés à 2.67 GHz. Notons que ceux-ci
peuvent exécuter 2 threads en parallèle : ce qui nous donne un total de 8 threads exécutables
en parallèle.
Pour ce qui est des cartes Tesla, chacune dispose de 240 SPs cadencés à 1.3 GHz : soit un
total de 480 SPs pour la station.
Au niveau mémoire, nous avons : 24 GB pour l’host et 4 GB par device.
Pour terminer, nous retrouverons toutes les spécifications sur les pages internet suivantes :
• X5550 : http://ark.intel.com/Product.aspx?id=37106
• C1060 : http://www.nvidia.com/object/product_tesla_c1060_us.html

Software

Pour ce qui est des logiciels disponibles sur lmgpu, ceux-ci sont repris à la table 4.3.

Logiciel Version
Noyau linux 2.6.18 (64 bits)
Drivers NVIDIA 195.36.15
gcc 4.1.2
CUDA Toolkit 3.0

Table 4.3 – Logiciels disponibles sur lmgpu

1. Institut de Calcul Intensif et de Stockage de Masse : http://www.uclouvain.be/cism

40
4.2.4 Plateforme gameboy

Hardware

La station gameboy est la pointure au-dessus de lmgpu : elle dispose de 2 CPUs Intel Xeon
X5550 et de 4 GPUs NVIDIA Tesla C1060.
Cette plateforme est détenue par l’unité de mécanique appliquée de l’Université Catholique
de Louvain.

Software

La table 4.4 reprend les logiciels disponibles sur gameboy.

Logiciel Version
Noyau linux 2.6.27 (64 bits)
Drivers NVIDIA 195.36.15
gcc 4.3.2
CUDA Toolkit 3.0

Table 4.4 – Logiciels disponibles sur gameboy

4.3 Multiplication matricielle : cas des matrices carrées

Tout au long de cette section, nous étudierons différents algorithmes de multiplication


matricielle : AB = C. Notons que toutes les matrices sont carrées et générées aléatoirement.
Notre étude consiste à mesurer les temps mis par les différentes implémentations de la
multiplication. Nous réaliserons ces mesures via la fonction clock_gettime.
Notons qu’afin de moyenner nos mesures, sauf mention du contraire, une même expérience
sera répétée 100 fois.
Pour ce qui est des mesures proprement dites, nous ne tiendrons pas compte du temps
consommé par la génération des matrices à multiplier. En d’autres termes :
• Pour les codes CPU, on ne mesure que le temps de calcul de la multiplication
• Pour les codes CUDA, on mesure le temps de calcul et les temps de transferts, montant
et descendant, entre host et device
• Pour les codes OpenCL, on mesure le temps de calcul, les temps de transferts et le
temps de compilation
Pour finir, les codes sources des différents algorithmes, ainsi que les résultats obtenus, sont
disponibles à l’adresse : http://www.student.montefiore.ulg.ac.be/~marsic

41
4.3.1 Implémentations naïves

Commençons par comparer trois implémentations naïves : une version purement CPU, une
CUDA et une OpenCL. La figure 4.3 reprend les différents résultats.
Notons que nous entendons par naïf, le fait que les codes ne tiennent compte d’aucune
optimisation, en particulier au niveau des accès mémoires.
Pour ce qui est du temps mis par l’algorithme CPU, il n’a plus été mesuré pour les tailles
de matrices supérieures à 800 × 800.

Codes Naifs −−− desktop


14
GPU Simple (CUDA)
GPU Simple (OpenCL)
12 CPU Simple

10

8
Temps [s]

0
100 200 300 400 500 600 700 800 900 1000
Taille de la Matrice

Figure 4.3 – Trois implémentations naïves de la multiplication matricielle (desktop)

Nous remarquons immédiatement l’accélération entre les versions GPUs et la version CPU.
Ainsi, sans trop de travail, des algorithmes lents peuvent être sensiblement accélérés.
Notons également un très léger ralenti de la version OpenCL par rapport à la version CUDA.
Ceci s’explique par la compilation à l’exécution : celle-ci consomme quelques fractions de
seconde. Cependant, sur de très grandes matrices, ce ralenti est négligeable.
Signalons également que ce ralenti est constant sur de grandes matrices. Ceci provient du
fait que le temps de compilation ne dépend pas de la taille des matrices.

42
4.3.2 BLAS

Nous avons vu qu’un algorithme naïf pouvait être sensiblement accéléré par GPU. Main-
tenant, comparons notre version CUDA naïve à une version CPU optimisée. Pour rappel, cette
version optimisée de BLAS est ATLAS. La figure 4.4 reprend les résultats obtenus.

BLAS −−− desktop


6
BLAS
GPU Simple

4
Temps [s]

0
100 200 300 400 500 600 700 800 900 1000
Taille de la Matrice

Figure 4.4 – Comparaison entre un algorithme CUDA naïf et un algorithme CPU optimisé
(desktop)

Dans ce cas-ci, nous observons immédiatement que la version CPU optimisée est de loin
meilleure que la version GPU naïve.
En conclusion, la parallélisation massive par GPU peut se révéler inutile, si aucun travail
d’optimisation n’est réalisé.

43
4.3.3 CUBLAS

Maintenant que nous savons qu’il importe d’optimiser, même en parallélisation massive,
observons l’effet de ces optimisations. La figure 4.5 compare les implémentations BLAS et
CUBLAS.

CUBLAS −−− desktop


1.4
BLAS
CUBLAS
1.2

0.8
Temps [s]

0.6

0.4

0.2

0
100 200 300 400 500 600 700 800 900 1000
Taille de la Matrice

Figure 4.5 – Comparaison entre BLAS et CUBLAS (desktop)

Sans surprises, avec la version GPU optimisée, nous retrouvons le gain très important de
la parallélisation massive.
Remarquons également l’accélération sur GPU, en passant d’une matrice 700 × 700 à une
800 × 800. Afin d’expliquer ce phénomène, commençons par tester CUBLAS pour un pas de
dimension unitaire. En d’autres termes, passons d’une matrice 1000 × 1000 à une 1001 × 1001,
puis à une 1002 × 1002, et ainsi de suite.

44
CUBLAS −−− lmgpu −−− Zoom
0.035

0.03

0.025
Temps [s]

0.02

0.015

0.01
1000 1010 1020 1030 1040 1050 1060 1070 1080 1090 1100
Taille de la Matrice

Figure 4.6 – Performance de CUBLAS pour une évolution progressive des matrices (lmgpu)

Nous pouvons remarquer une accélération importante à toutes les matrices 16 × 16. Il est
fort probable que l’implémentation de CUBLAS 3.0, pour la multiplication matricielle, utilise
des segments de mémoire de taille multiple de 16 × 16. Ainsi, toutes les matrices multiples
de ces segments offrent des chemins d’accès mémoires optimaux. Ce sont ces chemins d’accès
qui permettent une accélération substantielle.
Ce raisonnement est inspiré de [22]. L’argument de l’auteur est que la performance du code
de multiplication dépend des accès mémoires. Celui-ci propose un code, utilisant des blocs
mémoires de 64 × 16 pour A et de 16 × 16 pour B. Notons que ce code a servi d’inspiration
à CUBLAS 2.0. Étant donné que nous retrouvons des segments multiples de 16 × 16, nous
pouvons penser que peu de changements ont été effectués, pour la multiplication matricielle,
en passant de CUBLAS 2.0 à CUBLAS 3.0.
Il est important de noter que le raisonnement précédent n’est qu’une piste de réflexion.
Une explication plus précise demanderait l’accès aux sources de CUBLAS 3.0, ce qui n’est pas
possible à l’heure actuelle.
Pour terminer, notons que le test, dont est tirée la figure 4.6, n’a été réalisé qu’avec 10
échantillons par point, au lieu de nos 100 échantillons habituels. En effet, ce test demande un
très grand nombre de points. Ainsi, le nombre d’échantillons par point a été diminué pour
garder un temps de test raisonnable.

45
4.3.4 Scaling

Jusqu’à présent, nous avons exploité exclusivement la plateforme desktop. Regardons si,
en passant de 16 SPs à 240 SPs, l’accélération est visible. La figure 4.7 reprend les différentes
mesures.

Scaling −−− Simple (CUDA)


6
deskop (GeForce 8500GT)
gameboy (Tesla C1060)
4 lmgpu (Tesla C1060)
Temps [s]

0
100 200 300 400 500 600 700 800 900 1000

Scaling −−− Simple (OpenCL)


8

6
Temps [s]

0
100 200 300 400 500 600 700 800 900 1000

Scaling −−− CUBLAS


0.4

0.3
Temps [s]

0.2

0.1

0
100 200 300 400 500 600 700 800 900 1000
Taille de la Matrice

Figure 4.7 – Effet de scaling (GeForce 8500GT & Tesla C1060)

Nous remarquons immédiatement l’effet très marquant du passage de 16 à 240 SPs. Ce-
pendant, au niveau de l’implémentation OpenCL, le temps de compilation étant indépendant
du device, les petites matrices ne subissent pas l’effet du scaling 2 .

2. Nous entendons par scaling, l’augmentation du nombre de SP

46
4.3.5 Transferts mémoires

Jusqu’à présent, nous ne nous sommes intéressés qu’au temps de calcul proprement dit.
Maintenant, regardons de plus près le temps mis pour transférer des données.

CUBLAS −−− gameboy −−− Transferts


0.14
Host → Device
Device → Host
0.12 Calcul

0.1

0.08
Temps [s]

0.06

0.04

0.02

0
1000 1100 1200 1300 1400 1500 1600 1700 1800 1900
Taille de la Matrice

Figure 4.8 – Temps de transferts mémoires (gameboy)

Nous pouvons immédiatement conclure de la figure 4.8 que les temps de transferts mé-
moires sont négligeables face au temps de calcul, dans le cas de la multiplication de grandes
matrices carrées.

4.3.6 Multi–GPU

Jusqu’ici, nous n’avons utilisé qu’une seule des cartes des stations lmgpu et gameboy.
Exploitons à présent tout le potentiel de ces stations.

Répartition des matrices

Pour ce qui est de la répartition des matrices, dans le cas des 2 GPUs de la station lmgpu,
nous diviserons la matrice A en deux suivant ses lignes. Ensuite, nous distribuerons ces deux
sous-matrices entre les devices. Pour ce qui est de la matrice B, celle-ci sera copiée entièrement
sur les devices. Au niveau du résultat, chaque GPU aura sa sous-matrice de C en mémoire.
C’est au transfert des devices vers l’host que la matrice C sera reconstruite dans sa totalité.
Une illustration graphique de la répartition des matrices est disponible à la figure 4.9.

47
 
   
 
A1   C1
   
     
     
  =
 
× B
 
 
     
     
 A2  

 
 C2 
 

Figure 4.9 – Répartition des matrices pour 2 GPUs

Pour ce qui est de la division des matrices, pour la plateforme gameboy, nous procéderons
de façon analogue à lmgpu. Cependant, gameboy ayant 4 GPUs, nous diviserons également la
matrice B en deux suivant ses colonnes. Ainsi, la matrice C sera divisée en 4 sous-matrices.
Ensuite, chaque device pourra s’occuper d’une de ces sous-matrices. Nous illustrons ce para-
graphe à la figure 4.10.
 
   
 
A1   C11 C12
   
     
     
  =
 
× B1 B2
 
 
     
     
 A2  

 
 C21 C22 
 

Figure 4.10 – Répartition des matrices pour 4 GPUs

Notons que les nombres de lignes de A et de colonnes de B peuvent ne pas être multiples
de 2. Dans ce cas, les derniers GPUs devront traiter des matrices plus larges d’une ligne et/ou
d’une colonne. Cependant, ce surplus n’est pas gênant.

Accélération en temps d’exécution

Nous avons comparé le code de multiplication matricielle de CUBLAS, en distribuant les


matrices sur 1, 2 et 4 GPU. Les deux premiers tests ont été effectués sur lmgpu, tandis que le
dernier provient de gameboy. La table 4.11 reprend les résultats obtenus.
Notons que nous avons également vérifié si le passage de, respectivement, 1 à 2 GPUs et
1 à 4 GPUs, accélérait le calcul d’un facteur, respectivement 2 et 4. Sur la figure 4.11, nous
retrouvons, plus au moins, les facteurs d’accélération attendus.
Pour finir, la figure 4.12 permet de vérifier que chaque GPU est soumis à la même charge
que ses congénères. Nous remarquons que la distribution de la charge est, comme attendue,
homogène.

48
Multi−GPU −−− Scaling
0.14
1 GPU
2 GPUs
0.12 4 GPUs
Temps sur 2 GPUs × 2
Temps sur 4 GPUs × 4
0.1

0.08
Temps [s]

0.06

0.04

0.02

0
1000 1100 1200 1300 1400 1500 1600 1700 1800 1900
Taille de la Matrice

Figure 4.11 – Accélération apportée par le multi-GPU

Multi−GPU −−− lmgpu −−− Distribution


0.08
GPU0
0.06 GPU1
Temps [s]

0.04

0.02

0
1000 1100 1200 1300 1400 1500 1600 1700 1800 1900
Taille de la Matrice
Multi−GPU −−− gameboy −−− Distribution
0.04
GPU0
0.03 GPU1
GPU2
Temps [s]

GPU3
0.02

0.01

0
1000 1100 1200 1300 1400 1500 1600 1700 1800 1900
Taille de la Matrice

Figure 4.12 – Distribution de la charge entre les GPUs

49
Accélération en GFLOPS

Pour terminer notre étude sur le multi-GPU, nous avons évalué l’accélération en termes
d’opérations en virgule flottante. Pour avoir accès au nombre d’opérations via nos données
temporelles, nous avons exploité la relation suivante :

N 3 −9
GFLOPS = 2 10
t
où :
• N est la taille de la matrice (carrée)
• t est le temps de calcul
Cette formule se base sur le fait que la multiplication matricielle demande de l’ordre de 2N 3
opérations.
Les résultats de ce dernier test sont repris à la table 4.13.

FLOPS
1400
1 GPU
2 GPUs
1200 4 GPUs

1000

800
GFLOPS

600

400

200

0
1000 1100 1200 1300 1400 1500 1600 1700 1800 1900
Taille de la Matrice

Figure 4.13 – Accélération apportée par le multi-GPU (en GFLOPS)

Notons tout d’abord les impressionnants pics à environ 1300 GFLOPS 3 pour gameboy. Ce
résultat est à mettre en parallèle avec le nombre maximum d’opérations d’un GPU. Dans le
cas de la carte NVIDIA Tesla C1060, nous avons 622 GFLOPS par carte en simple précision 4 .
Soit 2488 GFLOPS pour les 4 cartes de gameboy, ce qui nous donne des pics à environ 50% de
la puissance maximale.
3. Cette valeur est à comparer à la centaine de GFLOPS théorique, atteinte par l’architecture Westmere,
dernière-née des laboratoires d’Intel
4. Ce résultat ne tiens pas compte du gain donné par les SFUs

50
Pour ce qui est des autres pics de gameboy, ceux-ci se situent à environ 400 GFLOPS, soit
seulement 16% de la puissance maximale.
Enfin, pour ce qui est des autres plateformes, nous retrouvons les rapports de 16% et 50%.
Partant des derniers constats, nous pressentons l’intérêt d’un algorithme exploitant la
structure de la sous-section 4.3.3 5 . Un tel algorithme pourrait, par exemple, allouer des ma-
trices un peu plus grandes, afin de satisfaire cette structure. Nous obtiendrons ainsi, para-
doxalement, une accélération substantielle tout en calculant plus d’éléments ! Pour rappel,
ceci est dû à l’importance des transferts mémoires dans la multiplication matricielle.
Pour terminer, notons que la figure 4.13 ne présente pas la structure de la figure 4.6. Ce
phénomène provient simplement du sous-échantillonnage de la taille des matrices. En effet,
à la figure 4.6 nous échantillonnons les matrices par pas de 1 × 1, tandis qu’à la figure 4.13
nous échantillonnons les matrices par pas de 100 × 100.

4.3.7 Double Précision

Jusqu’alors, nous n’avons exécuté que des codes en simple précision. Comme annoncé au
chapitre 1, le passage à la double précision s’accompagne d’une perte de vitesse. La figure 4.14
permet d’observer ce phénomène.

CUBLAS (double) −−− lmgpu


0.08
float
double
0.07

0.06

0.05
Temps [s]

0.04

0.03

0.02

0.01

0
100 200 300 400 500 600 700 800 900 1000
Taille de la Matrice

Figure 4.14 – Passage de la simple à la double précision (lmgpu)


5. Pour rappel, une division de A en blocs de 64 × 16, et de B en 16 × 16

51
Rappelons que ce phénomène s’explique par un nombre plus faible de SPs capables de
double précision. Pour la carte NVIDIA Tesla C1060, on compte un total de 240 SPs en
simple précision, contre 30 en double précision.
Nous venons d’observer le comportement en double précision de la plateforme lmgpu. Pour
rappel, celle-ci n’est pas basée sur l’architecture Fermi. A présent, observons le passage à la
double précision pour la plateforme fermi. Les résultats sont repris à la figure 4.15. Comme
nous pouvons le constater, nous avons également repris les valeurs obtenues pour la double
précision de la plateforme lmgpu.

CUBLAS (double) −−− fermi


0.12
float(fermi)
double(fermi)
double(lmgpu)
0.1

0.08
Temps [s]

0.06

0.04

0.02

0
100 200 300 400 500 600 700 800 900 1000
Taille de la Matrice

Figure 4.15 – Passage de la simple à la double précision (fermi)

Pour commencer, rappelons que la plateforme fermi implémente la version 4.0 du toolkit
CUDA, tandis que la plateforme lmgpu n’implémente que la version 3.0.
Revenons au graphique de la figure 4.15. Rappelons que la station fermi ne possède que
96 SPs. Cependant, nous remarquons des performances semblables, par rapport à la station
lmgpu, qui dispose de 240 SPs. Ce phénomène s’explique par le fait que l’architecture Fermi
dispose, proportionnellement, de plus de SPs capables de double précision. Ainsi, in fine,
malgré un plus petit nombre de SPs, les performances en double précision sont du même
ordre de grandeur.
Pour terminer cette section sur la double précision, comparons les performances des deux
plateformes précédentes, par rapport à une approche sur CPU. Les résultats sont disponibles
à la figure 4.16.

52
BLAS VS CUBLAS (double)
3.5
CPU
GPU (GT430)
3 GPU (C1060)

2.5

2
Temps [s]

1.5

0.5

0
100 200 300 400 500 600 700 800 900 1000
Taille de la Matrice

Figure 4.16 – Passage de la simple à la double précision : comparaisons CPU / GPU

Nous remarquons immédiatement que, malgré des performances réduites en double préci-
sion, le gain d’un calcul sur GPU reste important.

4.4 Multiplication matricielle : matrice carrée & matrice rec-


tangulaire

Jusqu’à présent, nous avons étudié le cas très particulier des matrices carrées. Cependant,
en pratique, il n’est pas rare d’avoir à multiplier une matrice carrée avec une matrice non
carrée.
   
   
     
   
   
     
     
A[N × N ] B[N × M ]  =  C[N × M ]
    
×
 

     
  

 
 


   
   
   
   

Figure 4.17 – Cas de la multiplication d’une matrice carrée par une matrice rectangulaire

53
Tester les performances d’une telle multiplication est compliqué, étant donné que nous
avons deux paramètres variables. En pratique, le nombre de colonnes, M , de la matrice non
carrée est faible. Par exemple, dans un problème électromagnétique résolu par éléments finis,
celui-ci est égal à 6. En effet, dans un tel problème, le nombre de variables est de 3 pour le
champ magnétique et de 3 pour le champ électrique, soit un total de 6 variables.
Dans le cadre de ce travail, nous appelons matrice rectangulaire, une matrice [N × M ]
telle que N  M ou N  M .
Dans le cadre des tests suivants, nous utiliserons des matrices non carrées de taille N × 6,
et nous ferons varier le paramètre N . Signalons que nous testerons nos codes directement en
double précision.
Comparons les performances de la plateforme fermi, dans le cas de la situation exposée
aux paragraphes précédents.

Multiplication Matricielle [N × N] × [N × 6] (double) −−− fermi


0.035
BLAS
CUBLAS
0.03

0.025

0.02
Temps [s]

0.015

0.01

0.005

0
1000 1100 1200 1300 1400 1500 1600 1700 1800 1900
Taille de la Matrice (N)

Figure 4.18 – Étude des performances dans le cas de la multiplication de matrices [N × N ] ×


[N × 6] (fermi)

Nous constatons directement des résultats plus mitigés, par rapport à la multiplication
de matrices carrées. En effet, nous constatons directement qu’il est plus avantageux d’utiliser
une version CPU de BLAS, pour de faibles valeurs de N . Signalons également que, même dans
le cas de grandes valeurs de N , le gain sur GPU n’est pas spectaculaire.
Cette diminution des performances est liée aux temps de transferts mémoires entre host
et device. En effet, dans le cas de matrices rectangulaires, le temps passé dans les transferts
mémoires n’est plus négligeable, face au temps de calcul, comme nous pouvons le voir à la
figure 4.19. Notons que celle-ci est à comparer avec la figure 4.8.

54
Multiplication Matricielle [N × N] × [N × 6] (double): Transfert −−− fermi
0.018
Calcul
0.016 Transferts: Host → Device → Host

0.014

0.012
Temps [s]

0.01

0.008

0.006

0.004

0.002

0
1000 1100 1200 1300 1400 1500 1600 1700 1800 1900
Taille de la Matrice (N)

Figure 4.19 – Étude des performances dans le cas de la multiplication de matrices [N × N ] ×


[N × 6] : temps de transferts (fermi)

A la vue de ces résultats, nous pouvons conclure que, dans le cas des matrices rectangulaire,
les temps de transferts mémoires ne sont pas négligeables. Ainsi, afin d’obtenir un code de
calcul performant, nous devrons, autant que faire ce peut, optimiser les transferts mémoires
entre host et device.
Cette contrainte nous forcera à étudier l’algorithme à implémenter au niveau des transferts
mémoires, afin de minimiser ceux-ci. Il nous sera donc plus difficile de porter un code sur GPU,
si celui-ci est exigeant en termes de multiplication par des matrices rectangulaires.
Observons à présent le comportement de la multiplication si nous faisons varier le para-
mètre N par pas unitaire, exactement comme nous l’avons fait dans le cas de la figure 4.6.
Les résultats sont disponibles à la figure 4.20.

55
Multiplication Matricielle [N × N] × [N × 6] (double) −−− fermi
0.0135

0.013

0.0125

0.012
Temps [s]

0.0115

0.011

0.0105

0.01

0.0095

0.009
1000 1020 1040 1060 1080 1100 1120 1140 1160 1180 1200
Taille de la Matrice (N)

Figure 4.20 – Étude des performances dans le cas de la multiplication de matrices [N × N ] ×


[N × 6] : variation de N par pas unitaire (fermi)

Nous pouvons constater qu’il n’y a pas de structure particulière, comme nous avons pu le
constater à la figure 4.6, dans le cas des matrices carrées.
Pour finir l’étude des matrices rectangulaires, signalons que plus la petite dimension de la
matrice monte, plus nous nous rapprochons d’une matrice carrée. Ainsi, le gain en performance
sera accru.

4.5 Multiplication matrice–vecteur

Abordons maintenant le problème de la multiplication matrice–vecteur. Dans ce travail,


nous aborderons le cas particulier d’une matrice carrée. En effet, il s’agit du cas de figure,
extrêmement courant, rencontré dans la résolution des systèmes d’équations algébriques.
Avant d’entrer dans le vif du sujet, remarquons que notre problème est un cas particulier
de la multiplication d’une matrice carrée par une matrice rectangulaire. En effet, un vecteur
peut être vu comme une matrice rectangulaire, où la petite dimension est unitaire. Cette
constatation nous laisse présager de mauvaises performances, lors du calcul sur GPU.
Étudions le cas de la multiplication d’une matrice [N ×N ] par un vecteur de dimension N .
Les résultats sont repris à la figure 4.21. Signalons que les calculs ont été réalisés sur la
plateforme fermi en double précision.

56
Multiplication Matrice − Vecteur (double) −−− fermi
0.016
BLAS
CUBLAS
0.014

0.012

0.01
Temps [s]

0.008

0.006

0.004

0.002

0
1000 1100 1200 1300 1400 1500 1600 1700 1800 1900
Taille de la Matrice (N)

Figure 4.21 – Étude des performances dans le cas de la multiplication d’une matrice carrée
par un vecteur (fermi)

Nous constatons immédiatement une chute dramatique des performances lors du calcul
sur GPU. Comme expliqué à la section précédente, cette diminution des performances est due
aux temps des transferts mémoires. Nous pouvons observer ce phénomène à la figure 4.22.
Comme nous pouvons le constater, dans le cas de la multiplication d’une matrice carrée
par un vecteur, les temps de transferts sont largement supérieurs aux temps de calculs. Il est
donc encore plus critique de minimiser les transferts mémoires, dans le développement d’un
code de calcul basé sur des multiplications matrice–vecteur.
Pour terminer, au niveau du comportement de la multiplication, dans le cas d’une variation
de la dimension par pas unitaire, nous n’observons aucune structure particulière. Ceci n’est
pas particulièrement étonnant, puisque, déjà dans le cas des matrices rectangulaires, nous
n’avons trouvé aucune structure. Les résultats sont repris à la figure 4.23.

57
Multiplication Matrice − Vecteur (double) −−− fermi
0.014
Calcul
Transferts: Host → Device → Host
0.012

0.01

0.008
Temps [s]

0.006

0.004

0.002

0
1000 1100 1200 1300 1400 1500 1600 1700 1800 1900
Taille de la Matrice (N)

Figure 4.22 – Étude des performances dans le cas de la multiplication d’une matrice carrée
par un vecteur : temps de transfert (fermi)

−3 Multiplication Matrice − Vecteur (double) −−− fermi


x 10
7

6.5

6
Temps [s]

5.5

4.5

4
1000 1020 1040 1060 1080 1100 1120 1140 1160 1180 1200
Taille de la Matrice (N)

Figure 4.23 – Étude des performances dans le cas de la multiplication d’une matrice carrée
par un vecteur : variation de la dimension par pas unitaire (fermi)

58
4.6 Multiplication vecteur–vecteur

Terminons ce chapitre par l’étude de la multiplication vecteur–vecteur. Dans le cadre de


ce travail, nous étudierons le cas très courant du produit scalaire. Au vu des résultats des
sections précédentes, nous nous attendons à des performances médiocres sur GPU.
Dans cette section, le cas test est simple : via la plateforme fermi, nous observerons le
temps de calcul, en double précision, du produit scalaire de deux vecteurs de taille N . Le
paramètre variable sera N . Les résultats sont reprise à la figure 4.24.

−3 Multiplication Vecteur − Vecteur (double) −−− fermi


x 10
1.8
BLAS
CUBLAS
1.6

1.4

1.2
Temps [s]

0.8

0.6

0.4

0.2

0
1 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9
Taille du Vecteur x 10
5

Figure 4.24 – Étude des performances dans le cas de la multiplication vecteur–vecteur (fermi)

Comme prévu, les performances s’écroulent totalement dans le cas du produit scalaire sur
GPU. En effet, nous observons un temps de calcul 10 fois plus court sur CPU. La raison provient
encore une fois des transferts mémoires.
Dans le cas du produit matrice–vecteur, nous avions déjà un temps de calcul plus faible
que le temps de transfert. Le passage à un produit scalaire ne peut, en aucun cas, améliorer
le rapport temps de calculs sur temps de transferts. En réalité, ce rapport est dégradé lors du
passage du produit matrice–vecteur au produit vecteur–vecteur. En effet, le produit scalaire
demande de l’ordre de N opérations, contre N 2 opérations pour le produit matrice–vecteur.
En conclusion, dans le cadre d’un code de calcul exploitant des produits scalaires, il est
impératif de minimiser les transferts mémoires.

59
4.7 Résumé & Conclusion

Au travers de ce chapitre, nous avons observé le comportement, sur GPU, de trois opérations
d’algèbre linaire :
• le produit matrice–matrice
• le produit matrice–vecteur
• le produit vecteur–vecteur
Nous avons pu constater une impressionnante accélération, lors du passage sur GPU, dans
le cas de la multiplication de matrices carrées. Rappelons que pour obtenir cette accélération,
nous avons utilisé une implémentation efficace des opérations d’algèbre linéaire sur GPU. Cette
implémentation porte le nom de CUBLAS.
Cette accélération ne provient pas tant de la parallélisation massive offerte par le GPU,
mais plutôt d’un bon rapport temps de calculs sur temps de transferts.
Dans les autres cas 6 , le passage sur GPU s’accompagnait de résultats mitigés. En effet, dans
ces cas de figure, les temps de transferts entre host et device ne peuvent plus être négligés.
Nous pouvons constater que, plus nous nous éloignons, en termes de dimensions, du produit
de matrices carrées, plus le rapport temps de calculs sur temps de transferts se détériore.
En conclusion, afin d’exploiter le potentiel de parallélisation massive sur GPU, nous devrons
étudier finement les transferts mémoires de l’algorithme à implémenter. En effet, l’utilisation
du GPU n’a de sens que si le rapport temps de calculs sur temps de transferts est grand. Dans
tous les autres cas, nous préférerons l’utilisation de CPUs 7 .
Signalons que les produits de grandes matrices carrées nous garantissent un rapport temps
de calculs sur temps de transferts grand. Ainsi, autant que faire ce peut, cette opération devra
être privilégiée.
Pour terminer, la figure 4.25 reprend les résultats principaux en termes de performances.

6. Pour rappel, il s’agit des produits matrice carrée–matrice rectangulaire, matrice carrée–vecteur et vec-
teur–vecteur
7. Notons que dans le cas d’une programmation hybride CPU/GPU, il est légitime de porter un code aussi
rapide sur CPU que sur GPU, afin de libérer le CPU de cette tâche

60
Multiplication Matrice − Vecteur (double) −−− fermi Multiplication Matricielle [N × N] × [N × N] (double) −−− fermi
0.016 3.5
BLAS BLAS
CUBLAS CUBLAS
0.014 3

0.012
2.5

0.01
2
0.008

Temps [s]
Temps [s]
1.5
0.006

1
0.004

0.002 0.5

0 0
1000 1100 1200 1300 1400 1500 1600 1700 1800 1900 100 200 300 400 500 600 700 800 900 1000
Taille de la Matrice (N) Taille de la Matrice

−3 Multiplication Vecteur − Vecteur (double) −−− fermi Multiplication Matricielle [N × N] × [N × 6] (double) −−− fermi
x 10
1.8 0.035
BLAS BLAS
CUBLAS CUBLAS
1.6
0.03

1.4
0.025
1.2

1 0.02

0.8

Temps [s]
Temps [s]

0.015

0.6

Figure 4.25 – Principaux résultats en termes de performances


0.01
0.4

0.005
0.2

0 0
1 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 1000 1100 1200 1300 1400 1500 1600 1700 1800 1900
Taille du Vecteur 5 Taille de la Matrice (N)
x 10

61
Chapitre 5

Problème de la vue adaptative

Jusqu’à présent, nous avons étudié, d’un point de vue relativement théorique, les possibilités
offertes par le calcul sur GPU. Les chapitres suivants se proposent d’étudier différents problèmes
pratiques, et d’étudier les performances lors du passage sur GPU.
Plus particulièrement, dans ce chapitre, nous étudierons le problème de la vue adaptative.
Nous commencerons par introduire le problème. Ensuite, nous proposerons le portage sur GPU
d’un code CPU. Pour finir, nous étudierons les performances de ce portage sur la plateforme fermi.

5.1 Introduction

Le problème traité dans ce chapitre est celui de la vue adaptative. Afin d’exposer le pro-
blème, imaginons une fonction quelconque définie sur un domaine. Nous désirons afficher une
couleur différente, suivant les valeurs prises par cette fonction.
Le problème est le suivant. Les librairies graphiques ne peuvent afficher que des variations
linéaires de couleurs, sur un domaine donné. Par exemple, si nous voulons représenter une
fonction variant en x2 , nous ne pourrons faire varier les couleurs qu’en x. Ainsi, l’image de
cette fonction n’en sera pas une représentation fidèle.
Une solution consiste à diviser le domaine, afin d’interpoler linéairement, et par morceaux,
la fonction à afficher. De cette façon, l’image sur l’écran reflètera plus fidèlement la fonction.
Illustrons ces derniers paragraphes par un exemple. Supposons que nous voulons afficher
une variation de couleur en x2 sur le domaine x ∈ [−1, 1]. La figure 5.1 reprend :
• La variation à afficher (courbe noire)
• La variation affichée, si nous interpolons directement sur le domaine [−1, 1] (courbe
bleue)
• La variation affichée, si nous interpolons sur une division en 4 sous-domaines (courbe
rouge)
Enfin, dans le cas de plusieurs domaines, nous pouvons diviser chaque domaine diffé-
remment et suivant les besoins. Cette solution porte le nom de vue adaptative. Celle-ci est
utilisée, entre autres, dans la visualisation de solutions éléments finis, où les éléments utilisés
sont d’ordre supérieur à un. Pour plus d’information, nous renvoyons le lecteur à [19].

62
1.5

Variation interpolée
linéairement

1
Variation interpolée
sur une division
du domaine

0.5 Variation
à afficher

0
−1.5 −1 −0.5 0 0.5 1 1.5

Figure 5.1 – Illustration du principe de vue adaptative

5.2 Implémentation

5.2.1 Base de travail

Dans ce travail, nous partirons du code de vue adaptative présent dans le logiciel open
source gmsh 1 .
Notre objectif sera de porter ce code sur GPU, en le modifiant le moins possible. A cette
fin, nous modifierons la classe fullMatrix, chargée de représenter les matrices et les vecteurs
utilisés par le code de vue adaptative.

5.2.2 Classe fullMatrix

Cette classe est disponible dans la partie ./Numeric/ du code de gmsh. Cette classe utilise
intensivement les opérations BLAS. Nous pouvons donc substituer les appels BLAS sur CPU,
par des appels à CUBLAS. Signalons qu’en plus de ces substitutions, nous devrons gérer les
transferts mémoires entre host et device.
Étant donné qu’a priori, nous ne savons pas comment cette classe sera utilisée, nous
utiliserons le schéma de transfert suivant. Pour chaque méthode appelant une fonction CUBLAS,
nous commencerons par charger les arguments sur le device. Ensuite, nous lancerons le kernel
de calcul. Et pour finir, nous rapatrierons les données sur l’host.
1. Logiciel de maillage, de pré-processing et de post-processing (voir [2]) : http://www.geuz.org/gmsh/

63
A titre d’exemple, prenons l’implémentation de la méthode gemm de fullMatrix. Soient
a, b et c des objets fullMatrix, et soient alpha et beta des entiers. Alors, c.gemm(a, b,
alpha, beta) revient à c := alpha * a * b + beta * c. Le code de cette méthode est
repris ci-après.
Code de la méthode gemm de fullMatrix

template < >


void fullMatrix < double >:: gemm ( const fullMatrix < double > &a ,
const fullMatrix < double > &b ,
double alpha , double beta ){
// Few initializations //
int rowA = a . size1 () , colA = a . size2 ();
int rowB = b . size1 () , colB = b . size2 ();
int M = _r , N = _c , K = colA ;
int LDA = rowA , LDB = rowB , LDC = _r ;
int Asize = rowA * colA , Bsize = rowB * colB , Csize = _r * _c ;

double * AD , * BD , * CD ; // Device pointers

cublasInit ();

// Device Memory //
cublasAlloc ( Asize , sizeof ( double ) , ( void **)& AD );
cublasAlloc ( Bsize , sizeof ( double ) , ( void **)& BD );
cublasAlloc ( Csize , sizeof ( double ) , ( void **)& CD );

// Host to Device Copy , et


cublasSetMatrix ( rowA , colA , sizeof ( double ) , a . _data , LDA , AD , LDA );
cublasSetMatrix ( rowB , colB , sizeof ( double ) , b . _data , LDB , BD , LDB );
cublasSetMatrix ( _r , _c , sizeof ( double ) , _data , LDC , CD , LDC );

// CUBLAS Call //
cublasDgemm ( ’N ’ , ’N ’ , M , N , K ,
alpha , AD , LDA , BD , LDB ,
beta , CD , LDC );

// Device to Host Copy //


cublasGetMatrix ( _r , _c , sizeof ( double ) , CD , LDC , _data , LDC );

// Free Device Memory //


cublasFree ( AD );
cublasFree ( BD );
cublasFree ( CD );

// Release CUBLAS resources //


cublasShutdown ();
}

Notons que ce code exploite les fonctions de haut niveau offertes par CUBLAS. En effet,
certaines primitives CUDA, telles que les copies mémoires, ont été abstraites dans CUBLAS.

64
Ainsi, par exemple, la copie montante d’une matrice s’effectue au moyen de la fonction
cublasSetMatrix. Toutes les fonctions CUBLAS sont reprises dans [9].
Le code complet du portage est disponible à l’adresse suivante : http://www.student.
montefiore.ulg.ac.be/~marsic.

5.3 Intégration du code CUBLAS dans gmsh

Pour commencer, signalons que nous avons utilisé uniquement les fonctions disponibles
dans la libraire CUBLAS. Or, cette librairie peut être utilisée indépendamment des librairies de
base CUDA. De plus, les kernels utilisés par CUBLAS sont déjà compilés. Ainsi, le compilateur
nvcc n’est d’aucune utilité.
Partant de ce constat, nous devons uniquement demander au compilateur host, de lier
notre code avec la librairie CUBLAS. Plus particulièrement, gmsh utilise la plateforme CMake
comme outil de compilation. Ainsi, afin de compiler notre classe fullMatrix modifiée, nous
devons ajouter les lignes suivantes au CMakeLists.txt du projet gmsh :
I N CL U D E_ D I RE C T OR I E S (/ P A TH _ T O_ C U D AT O O LK I T / include )
LINK_LIBRARIES ( " -L / P AT H _ TO _ C UD A T OO L K IT / lib / - lcublas " )

5.4 Résultats

5.4.1 Présentation du cas de test

Afin de comparer les implémentations CPU et GPU, utilisons le cas test suivant. Soit l’étude
par éléments finis de l’onde de pression au niveau d’un réacteur d’avion. Les éléments utilisés
lors de la résolution sont des polynômes de Lagrange d’ordre 8, dont le support est un triangle
(ce qui nous donne en tout 45 degrés de liberté par triangle). La solution, sans vue adaptative,
est reprise à la figure 5.2.

Figure 5.2 – Cas test sans vue adaptative (d’après [19])

Appliquons maintenant la vue adaptative, en divisant les triangles problématiques en 4,


puis en 16, et enfin en 64. Le schéma de division est repris à la figure 5.3. Les résultats de la
vue adaptative sont repris à la figure 5.4.

65
2

1
4
1 3

Figure 5.3 – Division d’un triangle en sous-triangles

Figure 5.4 – Cas test avec vue adaptative (d’après [19]) : division, de plus en plus fine, des
domaines problématiques (la dernière figure représente la solution finale sans son maillage)

5.4.2 Étude des performances

Observons maintenant le temps mis par les implémentations CPU et GPU. Les résultats sont
repris à la figure 5.5. Signalons que l’axe horizontal donne la division des triangles. Celle-ci
est exprimée en 4x , où x est la valeur représentée sur l’axe.
Nous constatons immédiatement l’effondrement des performances, lors du passage sur GPU.
Afin d’expliquer ce phénomène, observons la taille des matrices mises en jeu.
Dans le cas d’une division des triangles en 64 sous–triangles, nous notons 1635 multipli-
cations de matrices [45 × 3] par des matrices [3 × 3].
En conclusion, cette implémentation passe plus de temps à transférer les matrices entre
l’host et le device qu’à multiplier ces matrices !

66
Vue adaptative
70
CPU
GPU
60

50

40
Temps [s]

30

20

10

0
1 2 3 4 5 6 7 8 9
x
Division [4 ]

Figure 5.5 – Comparaison des performances

5.5 Conclusion

Le test, réalisé durant ce chapitre, montre l’importance d’une gestion efficace des transferts
mémoires.
Dans ce chapitre, nous avons tenté un portage sur GPU, d’un code existant sur CPU, en
modifiant le moins possible l’implémentation de départ. Dans ce but, la gestion des transferts
mémoires a été réalisée via une schéma unique préétabli.
Nous avons constaté une chute dramatique des performances, lors du passe sur GPU. La
cause de cette perte est la mauvaise gestion des transferts mémoires. Ainsi, une amélioration
possible serait la gestion explicite de ces transferts, afin de les optimiser. Cependant, cette
contrainte est en contradiction avec notre objectif premier, qui était de ne modifier que très
légèrement le code CPU.
En conclusion, le portage efficace d’un code CPU sur GPU devra s’accompagner, très pro-
bablement, d’une réécriture en profondeur du code. En effet, si aucun soin n’est apporté à
la gestion des transferts, les performances du code risquent d’être médiocres. Nous tenterons
une meilleure approche dans le chapitre suivant.

67
Chapitre 6

Méthode de Galerkin discontinue

Dans ce chapitre, nous étudierons un second portage d’un code CPU sur GPU. Partant des
conclusions du chapitre précédent, nous tenterons une modification en profondeur d’un code
existant, appelé dg.
Au niveau du code, il s’agit d’une implémentation de la méthode de Galerkin discontinue. Nous
commencerons ce chapitre par une brève description de la méthode. Ensuite, nous rentrerons dans
le vif du sujet, avec le portage du code CPU.
Comme toujours, nous trouverons les codes sources sur la page suivante : http://www.
student.montefiore.ulg.ac.be/~marsic

6.1 Description de la méthode de Galerkin discontinue

6.1.1 Méthode des éléments finis & Galerkin continu

La méthode des éléments finis est une technique permettant de calculer numériquement la
solution d’équations aux dérivées partielles. Soient un domaine Ω, D un opérateur différentiel
et u(x) une fonction, définie sur Ω, telle que :

u(x) : D(u) = f ∀x ∈ Ω (6.1)

Notre objectif est de trouver u.


La méthode des éléments finis nous propose de construire u, en n’utilisant qu’un ensemble
fini de fonctions de bases. Par exemple, si u est une fonction 1D, alors nous pouvons, par
exemple, construire u dans une base polynomiale :
M
u(x) = (6.2)
X
ui xi
i=1

Nous pouvons également construire u par morceaux. Si nous divisons le domaine Ω en N


sous-domaines 1 , alors nous aurons N fonctions u à construire. Afin de coupler ces équations,
la méthode de Galerkin continue pose des conditions de continuité entre les sous-domaines.
1. Cette opération de division de Ω en sous-domaines est appelée discrétisation, ou maillage, de Ω

68
Reste à trouver M équations, par sous-domaine, pour fixer les coefficients ui . Tout d’abord,
nous pouvons exploiter les conditions limites associées à u. Les équations restantes seront
générées en exploitant la formulation faible du problème :
Z Z
D(u) v dΩ = f v dΩ ∀v ∈ F (6.3)
Ω Ω

Cette équation devra être évaluée en un ensemble de points. Ces points sont les nœuds de
la discrétisation de Ω. Chaque évaluation nous donnera une nouvelle équation.
Les fonctions v sont appelées fonctions de forme. Chacune d’elles est associée à un nœud
de la discrétisation de Ω. La méthode de Galerkin propose de construire ces fonctions en
utilisant la même base de fonctions que u.
Un exemple simple de résolution est disponible à l’annexe A.
Une description plus complète de la méthode des éléments finis est disponible dans [1].

6.1.2 La méthode de Galerkin discontinue

Comme nous l’avons vu au point précédent, la méthode de Galerkin continue impose des
conditions de continuité entre les sous-domaines.
Pour ce qui est de la méthode de Galerkin discontinue, celle-ci impose des conditions de
conservation de flux.
Une des propriétés de cette approche est une meilleure parallélisation, par rapport à la
méthode continue. Plus précisément, le couplage entre les sous-domaines est plus efficacement
traitable en parallèle. En contrepartie, la méthode de Galerkin discontinue génère un plus
grand nombre d’inconnues, par rapport à la méthode continue.
Une description plus précise de la méthode est disponible dans [6].

6.2 Portage GPU du code dg : premier essai

Pour commencer, notons que le code dg est basé sur gmsh. Signalons également que cette
implémentation est toujours au stade expérimental.

6.2.1 Implémentation

Étant donné que dg se base sur gmsh, celui-ci utilise la classe fullMatrix, vue au chapitre
précédent. Dans celui-ci, nous avons vu qu’il n’était pas pertinent de modifier le code à un si
haut niveau. En effet, comme nous l’avons constaté, nous perdions tout contrôle sur la gestion
de la mémoire, et les performances s’en faisaient ressentir.
Cependant, au chapitre 4, nous avions vu que, dans le cas de la multiplication entre
de grandes matrices, les temps de transferts pouvaient être négligés. Plus précisément, ces
matrices devaient avoir un très grand nombre de lignes et de colonnes.
Notre premier portage consiste à écrire une version de fullMatrix, qui n’exploite le GPU,
que dans le cas de grandes matrices. Dans les autres cas, notre classe exploitera le CPU.

69
Les deux seules méthodes faisant intervenir des multiplications matricielles sont mult
et gemm. Nous ne modifierons donc que ces deux méthodes. Leurs codes seront de la forme
suivante :
Pseudo-code de mult et gemm

# define CUBLA S_MATR IX_MI N 999

template < >


void fullMatrix < double >:: mult_OR_gemm (...){
// Few initializations //
GetAllMatrixSize ();

// Check Input Matrices Size //


if ( rowA < CU BLAS_M ATRIX_ MIN || colA < C UBLAS_ MATRI X_MIN ||
rowB < CU BLAS_M ATRIX_MIN || colB < C UBLAS_ MATRI X_MIN ){

CPU_dgemm (); // Call CPU BLAS


}
else {
HostDeviceCopy (); // Upload Data
GPU_dgemm (); // Call GPU BLAS
DeviceHostCopy (); // Download Data
}
}

Comme nous pouvons le constater, la multiplication sera réalisée sur GPU, si et seulement
si la multiplication est strictement plus grande qu’une [999 × 999] × [999 × 999].

6.2.2 Étude des performances

Cas de test

Pour commencer, signalons que nous utiliserons la plateforme fermi durant tous nos tests.
Le code de dg dispose d’un grand nombre de scripts de test. Pour notre portage, il nous faut
un test mettant en jeu de très grandes matrices. Parmi les tests disponibles, nous disposons
d’une résolution 3D des équations de Maxwell, ce qui porte les inconnues à 6 par élément.
Afin de grossir les matrices, nous utiliserons également des éléments d’ordre élevé. Le code
dg reste stable pour des éléments d’ordre 7. Au-delà, des erreurs mémoires nous parviennent.
Nous utiliserons également un maillage du domaine d’étude très fin. Nous avons été capables
de générer un maillage de 3276 éléments d’ordre 7, soit 155569 nœuds. Encore une fois, pour
un maillage plus fin, le système générait des erreurs mémoires.

70
Résultats

Les résultats de ce test sont peu concluants. En effet, aucun produit plus grand que
[999 × 999] × [999 × 999] n’a été observé. Ainsi, toute la résolution a été réalisée sur CPU. Nous
avons essayé d’autres scripts de test, sans succès.
Afin d’observer le comportement du GPU, diminuons notre exigence sur la taille des ma-
trices. Portons le calcul sur GPU, pour une multiplication supérieure à [299 × 299] × [299 × 299].
Pour cette version modifiée, nous avons observé, pour le cas de résolution de l’équation de
Maxwell, une seule multiplication sur GPU. Celle-ci est du type [14400 × 330] × [330 × 2570].
Remarquons que cette multiplication a été observée à l’initialisation du code. Ainsi, nous
ne comparerons que les temps d’initialisation, entre les versions CPU et GPU.
Au niveau des résultats, les temps CPU et GPU sont relativement proches, comme nous
pouvons le voir à la table 6.1.

Temps CPU Temps GPU


43 s 39 s

Table 6.1 – Temps d’initialisation

En conclusion, cette première approche se révèle peu intéressante en pratique. En effet,


durant nos tests, nous n’avons jamais rencontré du multiplications matricielles importantes.
Signalons également que, même dans le cas d’une faible contrainte sur la taille des matrices,
peu de multiplications sont réalisées sur GPU.
Pour terminer, signalons qu’il existe des problèmes où le nombre d’inconnues est supérieure
à 6. Cependant, celles-ci dépassent rarement la dizaine.

6.3 Portage GPU du code dg : second essai

6.3.1 Amélioration par rapport au premier portage

A présent, entrons en profondeur dans le code de dg. Nous pouvons constater qu’une
opération assez coûteuse, en termes de multiplications matricielles, est l’appel à la méthode
multiplyByInvMassMatrix, de la classe dgDofContainer.
Cependant, en général, cette méthode utilise de petites matrices. Ainsi, si nous utilisons
le simple schéma :
1. Copie des données de l’host vers le device
2. Calcul sur le device
3. Copie des données du device vers l’host
Nous sommes certains d’obtenir des performances médiocres.
En observant le code de multiplyByInvMassMatrix, nous pouvons constater que, pour
un appel donné, nous sommes amenés à invoquer plusieurs fois la routine de multiplication
matricielle. De plus, les termes des différentes multiplications sont aisément accessibles.

71
Partant de ce constat, une idée serait d’exploiter un mécanisme de pipeline. En effet, si
nous pouvions, en parallèle :
1. Charger vers le device le jeu de matrices N − 2
2. Multiplier le jeu de matrices N − 1
3. Charger vers l’host le jeu de matrices N
Alors, nous serions en mesure de masquer les temps de transferts.
Plus précisément, si nous appliquons la méthode du paragraphe précédent, seuls le premier
transfert vers le device, et les quelques derniers transferts vers l’host, entrerons dans le bilan
temporel. En d’autres termes, si nous prenons N multiplications matricielles, le bilan temporel
sera de la forme :

T = 1 × tHost→Device + N × tcalcul + δ × tDevice→Host

où :
• tHost→Device est le temps moyen d’un transfert de l’host vers le device
• tDevice→Host est le temps moyen d’un transfert du device vers l’host
• δ tient compte du blocage du pipeline, provenant d’un mauvais équilibrage entre le
temps de transfert et le temps de calcul 2
• δ est compris dans l’intervalle [1, N ]

6.3.2 Implémentation

Étude du code multiplyByInvMassMatrix

Étudions le code multiplyByInvMassMatrix. La partie faisant intervenir la multiplication


matricielle est reprise ci-dessous.
Pseudo-code de la multiplication matricielle de multiplyByInvMassMatrix

for ( int i = 0; i < NbElements (); i ++) {


// Get matrices from bigger matrices
group . getBlockProxy (i , dataEl );
massMatrix . getBlockProxy (i , massEl );

// Here is the multiplication we want to pipeline


massEl . mult ( dataEl , tempElemData );

// Copy results elsewhere


dataEl . copy ( tempElemData ,0 , nbNodes ,0 , _nbFields ,0 ,0);
}

Partant de ce code, nous pouvons imaginer la technique de pipeline suivante :


1. Placer toutes les sous-matrices dataEl dans un vecteur
2. Placer toutes les sous-matrices massEl dans un autre vecteur
2. Nous renvoyons le lecteur à la section 2.6.5

72
3. Donner ces deux vecteurs à une version en pipeline de la multiplication
4. Récupérer un vecteur avec les résultats
5. Placer ces résultats aux bons endroits

Mémoire physique & Copies asynchrones

Rappelons que le mécanisme de pipeline demande des transferts mémoires asynchrones.


Dans ce but, nous devrons fixer en mémoire physique les matrices, dont proviennent les sous-
matrices dataEl et massEl.
Signalons que nous ne pouvons pas fixer directement les sous-matrices, car leurs pointeurs
ne sont pas forcément alignés sur 4 KB. En effet, nous avons cette garantie uniquement sur
les matrices de départ.
La matrice, dont proviennent les sous-matrices dataEl, est _data. Celle-ci est dispo-
nible directement dans la classe dgDofContainer. Nous lui ajouterons donc les méthodes
pageLockData et pageUnLockData, afin de bloquer et de débloquer _data en mémoire phy-
sique.
Pour ce qui est des sous-matrices massEl, celles-ci proviennent de la matrice _imass.
Celle-ci se situe dans la classe dgGroupOfElements. Ainsi, nous ajouterons à cette classe les
méthodes pageLockInverseMassMatrix et pageUnLockInverseMassMatrix. Celles-ci nous
permettront de bloquer et de débloquer _imass de la mémoire physique.
Remarquons que _imass et _data sont des instances de fullMatrix. Donc, c’est cette
classe qui sera chargée d’exécuter le code de blocage ou de déblocage 3 . Les méthodes associées
sont pageLock et pageUnLock.
Pour finir, signalons que nous avons modifié les constructeurs de fullMatrix, afin que
ceux-ci utilisent posix_memalign.

Réécriture de multiplyByInvMassMatrix

Fort de notre étude préliminaire, nous pouvons modifier le code de multiplyByInvMassMatrix.


La structure du nouveau code est disponible ci-dessous.
Pseudo-code de la nouvelle version de multiplyByInvMassMatrix

fullMatrix < double > * vData ;


fullMatrix < double > * vMass ;

// Allocate vectors of dataEl and massEl


vData = new fullMatrix < double >[ nbElements ];
vMass = new fullMatrix < double >[ nbElements ];

// Fill them with all dataEl and massEl


for ( int i = 0; i < nbElements ; i ++){
group . getBlockProxy (i , vData [ i ]);

3. Les méthodes des classes dgDofContainer et dgGroupOfElements ne se chargeront que d’envoyer un


message à la classe fullMatrix

73
massMatrix . getBlockProxy (i , vMass [ i ]);
}

// Lock Matrices
pageLockData (); // Lock * _data
group - > p a g e L o c k I n v e r s e M a s sM a t r i x (); // Lock iMass

// Launch piped gemm :


// vData [ i ] = vMass [ i ] * vData [ i ]
// for all i in nbElements
fullMatrix . pipeMult ( vMass , vData , vData , nbElements );

// Unlock Matrices
pageUnLockData (); // Unlock * _data
group - > p a g e U n L o c k I n v e r s e M a s s M a t r i x (); // Unlock iMass

// Update * _data ( thanks to dataEl )


for ( int i = 0; i < nbElements ; i ++){
group . getBlockProxy (i , dataEl );
dataEl . copy ( vData [ i ] ,0 , nbNodes ,0 , _nbFields ,0 ,0);
}

delete [] vData ;
delete [] vMass ;

Pour terminer, il nous reste à implémenter le code de la méthode pipeMult de fullMatrix.


Celle-ci sera chargée de lancer les différentes multiplications, dans une configuration tempo-
relle en pipeline.

Code de pipeMult

Ce code exploite la structure vue à la section 2.6.5. Nous lancerons d’abord toutes les
copies montantes. Ensuite nous lancerons les kernels. Et enfin, nous lancerons toutes les
copies descendantes. Tous ces appels seront asynchrones.
Le pseudo-code de pipeMult est disponible ci-dessous.
Pseudo-code de pipeMult

void fullMatrix < double >:: pipeMult ( fullMatrix < double > *a ,
fullMatrix < double > *b ,
fullMatrix < double > *c ,
int nbStream ){
// Allocate Device Memory //
double ** aD , ** bD , ** cD ;
aD = new double *[ nbStream ]; // Vector of matrices
bD = new double *[ nbStream ]; // Vector of matrices
cD = new double *[ nbStream ]; // Vector of matrices

for ( int i = 0; i < nbStream ; i ++){


A l l o c S p a c e F o r M a t r i c e s ( aD [ i ]);

74
A l l o c S p a c e F o r M a t r i c e s ( bD [ i ]);
A l l o c S p a c e F o r M a t r i c e s ( cD [ i ]);
}

// Create CUDA Stream //


cudaStream_t * stream = new cudaStream_t [ nbStream ];
for ( int i = 0; i < nbStream ; i ++)
cudaStreamCreate (&( stream [ i ]));

// Create CUDA Sync Event //


cudaEvent_t sync ;
cudaEventCreate (& sync );

// Async Host -> Device Copy //


for ( int i = 0; i < nbStream ; i ++){
copyMatrixAsync ( a [ i ] , aD [ i ] , stream [ i ]);
copyMatrixAsync ( b [ i ] , bD [ i ] , stream [ i ]);
}

// Async dgemm //
for ( int i = 0; i < nbStream ; i ++){
// Set CUBLAS Stream
c u b l a s S e t K e r n e l S t r e a m ( stream [ i ]);
// Launch Multiplication i in Stream i
cublasDgemm ( aD [ i ] , bD [ i ] , cD [ i ]);
}

// Async Device -> Host Copy //


for ( int i = 0; i < nbStream ; i ++){
copyMatrixAsync ( cD [ i ] , c [ i ] , stream [ i ]);
}

// Synchronize //
// Stream ’0 ’ Synchronizes on CUDA Context
cudaEventRecord ( sync , 0);
c u d a E v e n t S y n c h r o n i z e ( sync );

// Free //
freeAll ();
}

6.3.3 Étude des performances

Cas test

Pour cette version du code, nous avons repris le même script qu’au test précédent.

75
Ici, le paramètre étudié sera le temps d’exécution, en fonction du nombre d’itérations 4
demandé.

Résultats

Observons le temps mis par les implémentations CPU et GPU, pour différents nombres
d’itérations. Les résultats sont disponibles à la figure 6.1.

Code dg: version avec pipeline


900
CPU
GPU
800

700

600
Temps [s]

500

400

300

200

100

0
0 50 100 150 200 250 300 350
Nombre d’iterations

Figure 6.1 – Comparaisons des performances entre la version CPU et la version GPU, avec
pipeline, de dg

Pour commencer, nous pouvons constater qu’il est toujours plus avantageux d’utiliser une
implémentation CPU. Cependant, il est important de remarquer que la différence en temps de
calculs n’est pas très importante. En effet, pour 350 itérations, le code GPU n’est que 1.13 fois
plus lent. Signalons également que le code dg a été, à la base, totalement optimisé pour une
exécution sur CPU 5 .
Il est également intéressant d’observer les matrices manipulées par le code pipeMult. Nous
pouvons constater que les produits sont du type [120 × 120] × [120 × 6]. Clairement, au vu
des résultats de la section 4.4, sans pipeline, de tels produits auraient été beaucoup plus lents
sur GPU.
4. Notons que les équations de Maxwell, sont, dans ce script, résolues via un schéma itératif
5. Pout rappel, la plateforme fermi utilise, pour le CPU, une version hautement optimisée de BLAS, à savoir
ATLAS

76
Finalement, intéressons-nous à la longueur du pipeline, c’est-à-dire au nombre de multipli-
cations demandé à pipeMult. Dans notre cas, le pipeline est relativement long, à savoir 2570
multiplications. Signalons que, si le pipeline avait été plus court, par exemple une dizaine de
multiplications, l’intérêt de ce dernier aurait fortement diminué. En effet, plus le nombre de
multiplications est faible, moins nous pouvons masquer l’effet de transferts mémoires.
En conclusion, nous pouvons constater, qu’une modification en profondeur du code permet
d’améliorer les performances d’un portage sur GPU. Dans notre cas, cependant, le code final
est toujours légèrement moins performant sur GPU que sur CPU.
Cependant, par rapport au code de vue adaptative, où le portage avait été superficiel,
nous n’observons qu’un ralentissement de seulement 13%. Si nous revenons à la figure 5.5,
nous pouvons constater un ralentissement dépassant les 100%.

6.4 Conclusion

En conclusion de ce chapitre, nous avons pu observer le gain important apporté par une
étude fine du code CPU, lors d’un portage sur GPU. Via le mécanisme de pipeline, nous avons su
masquer les transferts mémoires, et ainsi augmenter la performance de notre implémentation
GPU.
En termes de performance, notre code final GPU est, à peu de choses près, aussi rapide
que le code CPU d’origine. Signalons qu’en plus d’offrir des performances égales, notre portage
permet une libération des ressources CPU. Le sujet du calcul hybride CPU/GPU n’est pas abordé
dans ce travail. Cependant, nous pouvons aisément imaginer les perspectives offertes par cette
approche.
Pour finir, notons que, jusqu’à présent, nous n’avons réalisé que des portages de codes
CPUs existants. Le dernier chapitre propose une approche différente. Nous y étudierons les
performances d’un code écrit directement pour GPU.

77
Chapitre 7

Trajectoires de particules chargées


dans un champ de force
électromagnétique

Dans ce chapitre, nous nous proposons d’écrire un code GPU, permettant de calculer les tra-
jectoires de particules chargées dans un champ de force électromagnétique. Contrairement aux
deux chapitres précédents, nous ne partirons pas d’un code CPU existant. Dans ce chapitre, nous
implémenterons notre code en partant simplement d’une feuille blanche. Notre objectif est d’op-
timiser chaque étape du code, afin d’obtenir une implémentation GPU, plus rapide qu’une version
CPU équivalente.
Nous commencerons ce chapitre par une présentation du problème posé. Ensuite, nous résou-
drons ce problème via une implémentation CUDA et une implémentation CPU équivalente. Pour
finir, nous comparerons les performances CPU et GPU.

7.1 Présentation du problème

Comme annoncé, notre problème est l’étude de la trajectoire de particules chargées dans
un champs de force électromagnétique. La connaissance de ces trajectoires peut être utile
dans les techniques de Physical Vapor Deposition, ou PVD. Celles-ci permettent la déposition
d’une couche mince d’atomes sur un substrat. Le problème est alors d’avoir une prédiction
de l’uniformité du dépôt.
Par exemple, dans le cas du magnetron sputtering 1 , nous exploitons le confinement d’élec-
trons via un magnétron, afin d’obtenir un plasma stable. Celui-ci servira alors à bombarder
une cible, dont on désire libérer les atomes. Ces atomes se déposeront, par affinité électronique,
en couche mince sur le substrat. Sans entrer dans les détails, la connaissance des trajectoires
de ces électrons peut nous donner un accès à la qualité du dépôt.
Dans ce chapitre, nous développerons un plugin, appelé CULorentz, pour le logiciel gmsh.
Celui-ci prendra en entrée un champ électrique constant, un champ d’induction magnétique
1. Une technique particulière de PVD

78
constant et un ensemble de particules. Le plugin sera alors chargé de calculer la trajectoire
de ces particules. Celles-ci seront obtenues via l’expression de la force de Lorentz 2 :

q
   
ẍ = e(x) + ẋ × b(x) (7.1)
m

où :
• x est la position de la particule à un temps donné
• q est la charge de la particule
• m est la masse de la particule
• e(x) est le champ électrique en x
• b(x) est le champ d’induction magnétique en x

7.2 Résolution

7.2.1 Algorithme de Beeman

Nous résoudrons l’équation différentielle (7.1) par l’algorithme de Beeman. Celui-ci se


base sur un schéma explicite prédicteur–correcteur. Plus précisément, cet algorithme estime
d’abord la vitesse en t, sans se baser sur la position en t + ∆t. Cette estimation nous permet
alors d’évaluer la position de la particule en t+∆t. Ensuite, via cette estimation de la position,
l’algorithme peut corriger la vitesse de la particule. Ainsi, nous pouvons calculer une meilleure
approximation de la position, en tenant compte de cette nouvelle vitesse.
Les expressions de la position (x), de la vitesse prédite(ẋp ) et de la vitesse corrigée (ẋc )
sont reprises ci-dessous.

 x(t + ∆t)
 = x(t) + ẋ(t) ∆t + 32 ẍ(t) ∆t2 − 16 ẍ(t − ∆t) ∆t2 + O(∆t4 )
ẋp (t + ∆t) = ẋ(t) + 23 ẍ(t) ∆t − 12 ẍ(t − ∆t) ∆t + O(∆t3 ) (7.2)
 ẋ (t + ∆t) = ẋ(t) + 1 ẍ(t + ∆t) ∆t + 5 ẍ(t) ∆t − 1 ẍ(t − ∆t) ∆t + O(∆t3 )

c 3 6 6

Comme nous pouvons le voir, ce schéma est d’ordre 4 pour la position et d’ordre 3 pour la
vitesse.

7.2.2 Implémentation CUDA

Implémentons à présent l’algorithme de calcul des trajectoires. Nous utiliserons pour ce


faire l’algorithme de Beeman. Au niveau de la parallélisation, chaque thread CUDA s’occupera
de la trajectoire d’une particule. Signalons que nous supposerons des particules initialement
au repos.
Nous ne donnerons qu’une version haut niveau de cette implémentation. Les codes sources
complets sont disponibles à l’adresse : http://www.student.montefiore.ulg.ac.be/~marsic.
Commençons par fixer quelques notations :
• X est la matrice position des particules. Elle possède T lignes (une par pas de temps)
et N colonnes (une par particule). Chaque composante de la matrice est un vecteur
2. Les interactions de Coulomb seront négligées

79
contenant les 3 coordonnées de la particule. La première ligne est initialisée avec les
positions initiales
• E est la matrice contenant le champ électrique interpolé sur une grille régulière
• B est la matrice contenant le champ d’induction interpolé sur une grille régulière
• DT est la taille des pas de temps
• ie et ib sont des vecteurs contenant les valeurs des champs électrique et d’induction
en un point donné
• Interp(X[i][j], E, B, ie, ib) interpole la valeur des champs E et B au point
X[i][j] et place les valeurs dans ie et ib
• AiPlus, Ai et AiMinus sont les vecteurs accélération en t + ∆t, en t et en t − ∆t
• ViP et Vi sont les vecteurs vitesses prédites et corrigées
• K est égal au rapport q/m de l’équation (7.1)
• cross(A, B) est le produit vectoriel des vecteurs A et B
Code haut niveau de l’algorithme de Beeman — Version CUDA

void beeman (){


// Copy on Device E , B and first row of X matrices
CopyOnDevice (E , all );
CopyOnDevice (B , all );
CopyOnDevice (X , firstRow );

// Call GPU kernel


beemanKernel < < <... , ... > > >();

// Copy on Host X matrix


CopyOnHost (X , all );
}

__global__ void beemanKernel (){


// Thread ID : one thread per particule //
int j = getThreadId ();

// Initialization //
Vi = {0. , 0. , 0.};
AiMinus = {0. , 0. , 0.};
Interp ( X [0][ j ] , E , B , ie , ib ); // Done on GPU !

// Beeman //
for ( int t = 1; i < T ; t ++){
// Acceleration at step i
Ai = K * ( ie + cross ( Vi , ib ));

// Position update
X [ t ][ j ] = X [ t - 1][ j ] + Vi * DT
+ (2/3 * Ai - 1/6 * AiMinus ) * DT * DT ;

// Predicted velocity at step i + 1


ViP = Vi + (3/2 * Ai - 1/2 * AiMinus ) * DT ;

// Predicted acceleration
Interp ( X [ t ][ j ] , E , B , ie , ib );

80
AiPlus = K * ( ie + cross ( ViP , ib ));

// Corrected velocity
Vi += (1/3 * AiPlus + 5/6 * Ai - 1/6 * AiMinus ) * DT ;

// Next step
AiMinus = Ai ;
}

7.2.3 Implémentation CPU

Au niveau de l’équivalent CPU de notre code, il nous suffira de parcourir, en série, chaque
particule.
Code haut niveau de l’algorithme de Beeman — Version CPU

// Initialization
InitAll ();

// Loop Over All particles


for ( int j = 0; j < particlesNb ; j ++)
beeman ( j );

7.3 Résultats

7.3.1 Occupation du GPU

Comme nous l’avons vu au chapitre 1, les ressources mémoires sont allouées pour tous les
blocs attachés à un même SM. Nous avons également vu que le nombre de blocs par SM était
diminué, si la quantité de ressources demandées était trop importante. Or, si nous observons
attentivement l’algorithme de Beeman, nous pouvons voir que celui-ci est fort gourmand en
termes de mémoires (accélération en i-1, en i, en i+1, vitesse prédite, vitesse corrigée, etc).
La société NVIDIA met à disposition un tableur 3 , permettant d’évaluer l’occupation d’un
GPU donné, en fonction des ressources mémoires exigées par l’application. En entrant les
données de notre implémentation dans ce tableur, nous pouvons constater une occupation
maximale de 33%. Cette valeur est obtenue pour des blocs de seulement 64 threads, soit 2
warps.
Cette faible occupation est inhérente à l’algorithme de Beeman, et ne pourra être modi-
fiée qu’en changeant de schéma de résolution. Cependant, cette faible valeur ne signifie pas
forcément que le code GPU sera plus lent que le code CPU. En réalité, comme nous le verrons
par la suite, le passage sur GPU nous offre un gain non négligeable.
3. Disponible à l’adresse : http://developer.download.nvidia.com/compute/cuda/4_0_rc2/sdk/docs/
CUDA_Occupancy_Calculator.xls

81
7.3.2 Performance

Premier cas test : un cube

Pour notre premier cas test, nous prendrons un cube, soumis à un champ électrique et un
champ d’induction magnétique, comme présenté à la figure 7.1.

(a) Champ électrique (b) Champ d’induction magnétique

Figure 7.1 – Champs électrique et d’induction magnétique (premier cas test)

Nous placerons au centre de ce cube N électrons. Ensuite, nous calculerons leurs tra-
jectoires, via nos codes GPU et CPU, sur la plateforme fermi en double précision. Finalement,
nous comparerons les temps mis par les deux implémentations en fonction de N . Les différents
paramètres du plugin sont repris à la table 7.1. Les résultats sont disponibles à la figure 7.2.

Nombre d’itérations 50000


Pas de temps ∆t 10−13 s
Charge de la particule (électron) −1.602176487 × 10−19 C
Masse de la particule (électron) 9.10938215 × 10−31 kg

Table 7.1 – Paramètres du plugin CULorentz (premier cas test)

82
Code CULorentz
14
CPU
CPU (dual core)
12 GPU

10

8
Temps [s]

0
100 150 200 250 300 350 400 450 500 550
Nombre de particules

Figure 7.2 – Comparaison entre les versions CPU et GPU (premier cas test)

Tout d’abord, signalons que la plateforme fermi dispose de deux cœurs. Donc, pour le code
CPU, nous pourrions paralléliser la boucle extérieure, et diviser ainsi le temps d’exécution par
deux. C’est pourquoi, nous avons ajouté les temps CPU divisés par deux à la figure 7.2.
Nous constatons immédiatement l’accélération offerte par le GPU, même dans le cas d’une
version parallélisée du code CPU. Ainsi, pour 550 particules, le code GPU est 3.6 fois plus rapide
que la version double cœurs sur CPU.
Pour finir, remarquons que le code génère des erreurs mémoires au-delà de 550 particules.
Une solution serait d’organiser notre algorithme en pipeline, afin de ne pas trop allouer de
mémoire sur le GPU.

Second cas test : un magnétron

Étudions à présent le confinement des électrons dans un magnétron, dans le cas de figure
du magnetron sputtering. Pour rappel, comme expliqué au début de ce chapitre, il s’agit d’une
technique de dépôt en couche mince.
Notre cas test est le suivant. Observons le confinement de 49 électrons. Les différentes
trajectoires, ainsi que les positions de départ sont disponibles à la figure 7.3. Pour ce qui est
du design du magnétron, celui-ci est tiré de l’article [21]. Nous trouverons également dans cet
article les différentes valeurs des conditions limites. Dans notre résolution, les champs ont été
obtenus par un modèle éléments finis. Celui-ci est disponible sur la même page web que le
code de CULorentz.

83
(a) Vue de face (b) Vue 3D

(c) Vue du dessus (d) Positions initiales

Figure 7.3 – Résultats du second cas test

84
Comme nous pouvons le constater, les trajectoires confinées forment un anneau. Ce ré-
sultat nous rassure quant à la qualité de notre plugin, étant donné que ce phénomène est
également décrit dans [21].
Au niveau des performances, les différents temps d’exécutions sont repris à la table 7.2.
Notons que notre cas test ne fait intervenir qu’un petit nombre de particules. Ainsi, sans trop
de surprises, nous observons une diminution des performances sur GPU.

Temps CPU Temps GPU


5.8 s 7.6 s

Table 7.2 – Temps d’exécutions pour 49 particules (second cas test)

Le nombre maximal de particules est 65 pour la plateforme fermi. Au-delà, le système est
à court de mémoire 4 . Pour ces 65 électrons, les performances CPU et GPU sont comparables,
comme nous pouvons le constater à la table 7.3.

Temps CPU Temps GPU


7.2 s 7.8 s

Table 7.3 – Temps d’exécutions pour 65 particules (second cas test)

Pour finir, comme pour le cas test précédent, une solution au problème de mémoire serait
d’utiliser un mécanisme de pipeline, afin de diminuer la quantité de mémoire demandée au
GPU.

7.4 Conclusion

Dans ce chapitre, nous avons développé avec succès un algorithme de calcul de trajectoires
de particules chargées dans un champ de force électromagnétique. Notre implémentation GPU
s’est révélée significativement plus performante qu’une implémentation CPU équivalente, dans
le cas d’un nombre important de particules.
Ces bons résultats proviennent en partie d’une bonne gestion des transferts mémoires,
c’est-à-dire, d’un rapport temps de calculs sur temps de transferts suffisamment grand. Cet
objectif a pu être atteint en écrivant notre code à partir de zéro.

4. Afin d’avoir 30 ns de trajectoires, nous avons calculé 300000 pas de temps avec ∆t = 10−13 s, ce qui est
très lourd en termes de mémoire

85
Conclusion

Récapitulatif

Avant d’entrer dans le détail des conclusions de ce mémoire, commençons par récapituler
le travail réalisé. Nous avons commencé par une description de l’architecture des processeurs
graphiques, et nous avons mis en évidence les différentes limitations de celle-ci. Ensuite, après
avoir décrit deux langages de programmation pour GPU, nous nous sommes intéressés aux per-
formances de différentes opérations d’algèbre linéaire. Enfin, nous avons tenté d’implémenter
une version GPU, pour trois problèmes de calcul scientifique.
Rappelons les résultats obtenus pour les trois dernières implémentations. Nous avons
commencé par un portage superficiel d’un code de vue adaptative. Cependant, les résultats
de ce portage se sont avérés médiocres.
Ensuite, afin d’exploiter au mieux les architectures graphiques, nous avons tenté le portage
en profondeur d’un code, implémentant la méthode de Galerkin discontinue. Grâce au méca-
nisme de pipeline, nous sommes arrivés à une version aussi rapide sur CPU que sur GPU. Ce bon
résultat ouvre la porte d’une programmation hybride CPU/GPU, puisque le microprocesseur est
libéré durant le traitement sur carte graphique.
Pour terminer, afin d’optimiser au mieux les transferts mémoires, nous avons réalisé un
code de calcul de trajectoire de particules chargées, en partant d’une feuille blanche. Cette
construction depuis zéro nous a permis d’obtenir un code significativement plus rapide sur
GPU que sur CPU.

Résultats

Le premier résultat de ce travail, et aussi le plus important, concerne l’influence du rapport


temps de calculs sur temps de transferts, sur les performances du traitement sur GPU. En effet,
au travers de plusieurs exemples, nous avons montré que, malgré la parallélisation massive
offerte par les cartes graphiques modernes, les performances d’un code de calcul sur GPU
peuvent s’écrouler si les temps de transferts sont trop grands par rapport aux temps de
calcul.
Le second résultat de ce mémoire est le suivant. Le portage d’un code CPU sur GPU, devra
être réalisé, très probablement, au prix d’une modification profonde du code. Celle-ci aura
pour objectif d’optimiser les temps de transferts, afin d’augmenter le rapport temps de calcul
sur temps de transferts. Le seul cas de figure où le portage ne nécessitera que de très légères

86
modifications, est celui d’un code, exploitant massivement le produit de grandes matrices
carrées.
Comme conséquence directe du dernier résultat, nous avons montré qu’il était préférable
de partir d’une base vierge, dans le développement de codes sur GPU. Ainsi, l’optimisation des
transferts mémoires peut être mieux appréhendée.

Perspectives

Au travers de ce mémoire, nous avons exploré les possibilités offertes par un calcul uni-
quement sur GPU. Cependant, comme pressenti avec notre code Galerkin discontinu, un large
champ d’applications peut être ouvert, grâce au calcul hybride CPU/GPU. En effet, même si
le gain sur GPU n’est pas toujours impressionnant, son utilisation permet la libération des
ressources CPU. Ainsi, le microprocesseur peut être utilisé pour d’autres tâches.
A l’heure actuelle, de nombreux investissements sont réalisés dans le domaine du calcul
hybride. Le plus impressionnant d’entre eux, est le super-calculateur chinois Tianhe-1A, classé
premier au rang mondial en novembre 2010, qui n’embarque pas moins de 7168 cartes gra-
phiques NVIDIA Tesla M2050 et 14336 microprocesseurs Intel Xeon X5670. Tout l’enjeu est
de savoir si les futures programmes du calculateur pourront exploiter tout ce potentiel.

87
Annexe A

Résolution éléments finis de


l’équation de Poisson 1D

Illustrons la méthode des éléments finis par un exemple simple. Prenons le domaine 1D
de la figure A.1. Résolvons l’équation de Poisson suivante :

 ∆u
 = 0
u(0) = 0 (A.1)
 u(1) = V

x
0 1

Figure A.1 – Domaine d’étude

Discrétisons notre domaine en 2 sous-domaines : A et B. Cette division est reprise à la


figure A.2.
Noeud 1 Noeud 2 Noeud 3

0 A 1/2 B 1
x

Figure A.2 – Domaine discrétisé

La fonction u, sera construite comme suit :


 h i
 uA (x) = u1 x + u0 ∀x ∈ 0, 12


u(x) = h i (A.2)
 u (x) = u3 x + u2 2, 1

1
∀x ∈

B

En exploitant les conditions limites de (A.1), nous pouvons écrire :


(
u(0) = 0 ⇐⇒ u0 = 0
(A.3)
u(1) = V ⇐⇒ u2 = V − u3

88
Ainsi, en combinant (A.2) et (A.3), nous obtenons :
(
uA = u1 x + 0 = u1 x
(A.4)
uB = u3 x + V − u3 = u3 (x − 1) + V

La condition de continuité impose :

uA (1/2) = uB (1/2)
1 1
 
⇐⇒ u1 = u3 −1 +V
2 2
⇐⇒ u1 = −u3 + 2V

Ainsi, l’équation (A.4) devient :


(
uA = (2V − u3 ) x
(A.5)
uB = u3 x − u3 + V

Il nous reste à fixer le terme u3 . Dans ce but, exploitons la formulation faible de l’équation
de Poisson (voir [1]) :
Z 1
∂u ∂v
dx = 0 (A.6)
0 ∂x ∂x

Évaluons cette équation au nœud 2. Prenons, pour ce nœud, la fonction de forme v suivante :
 h i
 vA

 = 2x ∀x ∈ 0, 12
v(x) = h i (A.7)
= 2 − 2 x ∀x ∈ 2, 1

 v
 1
B

Celle-ci est illustrée à la figure A.3.


Fonction de forme du noeud 2
1

0.8

0.6
v

0.4

0.2

0
0 0.2 0.4 0.6 0.8 1
x

Figure A.3 – Fonction de forme associée au nœud 2

89
Vu (A.5) et (A.7), l’équation (A.6) devient :
1 Z 1
∂uA ∂vA ∂uB ∂vB
Z
2
dx + dx = 0
0 ∂x ∂x 1
2
∂x ∂x
Z 1 Z 1
2
⇐⇒ (2V − u3 ) 2 dx + u3 (−2) dx = 0
1
0 2
  1   1
2
⇐⇒ (2V − u3 ) 2 x + u3 (−2) x 1 = 0

0 2

⇐⇒ 2V − u3 − 2 u3 + u3 = 0
⇐⇒ u3 = V (A.8)

Donc, finalement, vu (A.5) et (A.8), la solution éléments finis du problème est :


 h i
 uA

 = Vx ∀x ∈ 0, 12
h i
= Vx 2, 1

1
 u ∀x ∈

B

Ce qui revient à :
u=Vx ∀x ∈ [0, 1] (A.9)

Cette solution est identique à la solution analytique du problème.

90
Bibliographie

[1] Dular, P., and Geuzaine, C. ELEC0041-1 : Modélisation et conception des systèmes
électromagnétiques, 2010.
[2] Geuzaine, C., and Remacle, J.-F. Gmsh : a three-dimensional finite element mesh
generator with built-in pre- and post-processing facilities. International Journal for
Numerical Methods in Engineering Vol. 79, Issue 11 (September 2009), Pages 1309 –
1331.
[3] Intel Corporation. Intel X38 Express Chipset : Datasheet, 2007.
[4] Khronos OpenCL Working Group. The OpenCL Specification Version 1.1, 2010.
[5] Kirk, D. B., and Hwu, W. W. Programming Massively Parallel Processors : A Hands–
on Approach. Morgan Kaufmann Publishers, 2010.
[6] Lambrechts, J. Finite Element Methods for Coastal Flows : Application to the Great
Barrier Reef. PhD thesis, Université catholique de Louvain, 2011.
[7] NVIDIA Corporation. Technical Brief : NVIDIA GeForce 8800 GPU Architecture
Overview, 2006.
[8] NVIDIA Corporation. Technical Brief : NVIDIA GeForce GTX 200 GPU Architec-
tural Overview, 2008.
[9] NVIDIA Corporation. CUBLAS Library Version 3.0, 2010.
[10] NVIDIA Corporation. The CUDA Compiler Driver NVCC, 2010.
[11] NVIDIA Corporation. CUDA Compute Architecture : Fermi, 2010.
[12] NVIDIA Corporation. CUDA Programming Guide Version 3.0, 2010.
[13] NVIDIA Corporation. NVIDIA Compute — PTX : Parallel Thread Execution ISA
Version 2.0, 2010.
[14] NVIDIA Corporation. NVIDIA CUDA Reference Manual Version 3.0, 2010.
[15] NVIDIA Corporation. NVIDIA GF100 : Whitepaper, 2010.
[16] NVIDIA Corporation. OpenCL Programming Guide for the CUDA Architecture Ver-
sion 2.3, 2010.
[17] NVIDIA Corporation. CUDA API Reference Manual Version 4.0, 2011.
[18] OpenMP Architecture Review Board. OpenMP Application Program Interface,
2008.
[19] Remacle, J.-F., Chevaugeon, N., Marchandise, E., and Geuzaine, C. Efficient
visualization of high-order finite elements. International Journal for Numerical Methods
in Engineering Vol. 69, Issue 4 (January 2007), Pages 750 – 771.

91
[20] Sanders, J., and Kandrot, E. CUDA by Example : An Introduction to General–
Purpose GPU Programming. Addison–Wesley Professional, 2010.
[21] Shon, C., Lee, J., Lee, H., Yang, Y., and Chung, T. Velocity distributions in
magnetron sputter. IEEE Transactions on Plasma Science 26, 6 (December 1998), 1635
– 1644.
[22] Volkov, V., and Demmel, J. W. Benchmarking GPUs to tune dense linear algebra.
In ACM/IEEE Conference on Supercomputing (2008).
[23] Wolper, P. INFO0012-1 : Structure des ordinateurs, 2009.

92

Vous aimerez peut-être aussi