Vous êtes sur la page 1sur 34

Université Henri Poincaré – Nancy I

Adaptation d’algorithmes itératifs


asynchrones sur un cluster de GPU

MÉMOIRE

soutenu le 2 juillet 2009

pour l’obtention du

Master de Recherche de l’Université Henri Poincaré – Nancy I


(Spécialité Informatique)

par

Thomas Jost

Composition du jury
Olivier Perrin Maı̂tre de Conférences à l’Université Nancy 2
Bernard Girau Professeur à l’Université Henri Poincaré – Nancy 1
Encadrants : Sylvain Contassot-Vivier Professeur à l’Université Henri Poincaré – Nancy 1
Stéphane Vialle Enseignant-chercheur à Supélec

Laboratoire Lorrain de Recherche en Informatique et ses Applications — UMR 7503


2
TABLE DES MATIÈRES 3

Table des matières

Remerciements 4

Introduction 5

1 Présentation du problème 7
1.1 Problème étudié . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
1.1.1 Description mathématique du modèle de transport . . . . . . . . . . . . . 7
1.1.2 Modèle de calcul . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
1.2 Algorithmes synchrones et asynchrones . . . . . . . . . . . . . . . . . . . . . . . . 9
1.3 Intérêt des GPU . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
1.3.1 Architecture matérielle . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
1.3.2 Aspects logiciels . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12

2 Solveur linéaire sur GPU 15


2.1 Ressources disponibles pour l’algèbre linéaire sur GPU . . . . . . . . . . . . . . . . 15
2.1.1 Opérations matricielles avec CUBLAS . . . . . . . . . . . . . . . . . . . . . . 15
2.1.2 Solveurs linéaires existants . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
2.2 Contraintes pour une nouvelle implémentation . . . . . . . . . . . . . . . . . . . . 17
2.2.1 Méthodes directes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
2.2.2 Méthodes itératives . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19
2.2.3 Amélioration de la précision . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
2.3 Implémentation et performances . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
2.3.1 Un premier solveur dense . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
2.3.2 Format de stockage des données . . . . . . . . . . . . . . . . . . . . . . . . . 22
2.3.3 Algorithme de Jacobi/Gauss-Seidel . . . . . . . . . . . . . . . . . . . . . . . 23
2.3.4 Algorithme du gradient biconjugué . . . . . . . . . . . . . . . . . . . . . . . 24
2.3.5 Performances . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24
2.4 Conclusion sur les solveurs linéaires sur GPU . . . . . . . . . . . . . . . . . . . . . 27

3 Pistes pour la suite du stage 29


3.1 Solveur linéaire sur GPU . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29
3.1.1 Améliorations algorithmiques . . . . . . . . . . . . . . . . . . . . . . . . . . 29
3.1.2 Tests sur d’autres cartes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29
3.1.3 Utilisation différente du solveur . . . . . . . . . . . . . . . . . . . . . . . . . 30
3.2 Problème de transport . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30

Conclusion 33

Références 34
4 Remerciements

Remerciements

Je tiens tout d’abord à remercier Stéphane Vialle et Sylvain Contassot-Vivier pour leur aide,
leurs conseils et leur soutien dans ce projet. Merci également à Patrick Mercier, qui fait beau-
coup d’efforts pour que l’on puisse travailler dans de bonnes conditions sur le cluster de Supé-
lec. Un grand merci à Philippe Dessante pour ses conseils éclairés sur les méthodes numériques
utilisables dans ce projet. Merci à tous mes camarades de l’option IIC ainsi qu’à l’ensemble de
mes professeurs pour leur sympathie et la bonne humeur dans laquelle s’est passée toute l’an-
née 2008-2009. Merci également à tous les membres d’AlGorille pour l’accueil chaleureux qu’ils
m’ont réservé au sein de leur équipe.
Merci enfin à ma famille et tout spécialement à Katia pour son soutien et ses encourage-
ments de tout instant.
Introduction 5

Introduction

Dans le monde scientifique, l’étude de nombreux phénomènes physiques ou biologiques


repose sur des simulations numériques. L’objet de ce stage est de simuler un modèle 3D du
transport d’espèces chimiques polluantes dans un cours d’eau.
L’intérêt de cette étude n’est cependant pas ce modèle lui-même, mais la façon dont il est
implémenté. En effet, les algorithmes les plus couramment utilisés sur les clusters pour la si-
mulation de phénomènes bio-chimiques sont des algorithmes synchrones, et ceux-ci s’exé-
cutent sur des processeurs conventionnels, similaires à ceux que l’on peut trouver dans tout
ordinateur personnel. Ici, l’objectif est de combiner deux techniques de pointe pour accélerer
la simulation.

On cherche tout d’abord à déterminer s’il peut être intéressant d’utiliser des GPU pour ce
genre de calculs. En effet, bien que les GPU soient plus puissants que les CPU conventionnels
en termes de puissance brute, leur utilisation est plus délicate car elle nécessite une program-
mation très spécifique, en prenant en compte un parallélisme massif, et avec des contraintes
très fortes sur la mémoire et les transferts de données. On s’intéresse donc à la faisabilité de
l’écriture sur GPU d’un simulateur, ainsi qu’à ses performances tant en temps qu’en précision
de calcul.
Dans un second temps, l’objectif de ce stage est d’utiliser les GPU avec des algorithmes
asynchrones. Cette classe d’algorithmes est relativement nouvelle, et à ce jour il n’y a virtuel-
lement aucune étude concernant l’utilisation conjointe de ces algorithmes asynchrones et de
GPU. L’idée est donc de réaliser un simulateur de transport 3D d’espèces chimiques ; ce simula-
teur utilisera des algorithmes asynchrones pour les itérations et communications entre chacun
des nœuds du cluster sur lequel il fonctionnera, et chacun des nœuds de ce cluster utilisera une
carte GPU pour accélérer autant que possible les calculs qui lui sont attribués.
Au final, on aura donc deux niveaux d’asynchronisme, à des échelles temporelles très diffé-
rentes : au niveau des transferts réseau entre les différentes machines, et au niveau des trans-
ferts mémoire entre la RAM globale et la RAM du GPU.
Si l’on sait aujourd’hui que les algorithmes asynchrones sont plus efficaces que leurs équi-
valents synchrones dans le cadre des traitements itératifs parallèles, on ne sait pas encore com-
ment ils interagissent avec les GPU ou dans le cadre d’une architecture hybride CPU/GPU.
L’objet de ce stage est donc d’évaluer l’intérêt de cette approche, tant en termes d’accélération
brute qu’en termes de précision de calcul. Ce stage de recherche a commencé en mars 2009 et
se déroulera jusqu’à la fin du mois d’août 2009 au sein de l’équipe AlGorille du LORIA.

Dans ce rapport, nous détaillerons tout d’abord la nature du problème étudié, ainsi que les
algorithmes asynchrones et les GPU. Dans une seconde partie, nous présenterons ce que nous
avons accompli dans le cadre du calcul sur GPU, en décrivant les solutions déjà existantes, les
choix que nous avons faits et leurs motivations, les algorithmes utilisés, leur implémentation,
et les performances obtenues. Enfin, nous décrirons ce qu’il reste à faire jusqu’à la fin du stage.
6 Introduction
1. Présentation du problème 7

Chapitre 1

Présentation du problème

1.1 Problème étudié


L’objectif de ce stage est d’adapter aux GPU les travaux réalisés à Belfort par MM. Bahi,
Couturier, Mazouzi et Salomon concernant un modèle de transport en 3D d’espèces chimiques
dans un cours d’eau [BCMS06].

L’idée générale est de modéliser le déplacement de polluants en eaux peu profondes. L’ob-
jectif de cette simulation est de pouvoir observer les effets à long terme d’une pollution sur un
écosystème, ce qui est d’un grand intérêt pour la gestion de l’environnement. Le résultat de la
simulation est la concentration des différentes espèces chimiques considérées dans l’espace et
dans le temps.
Idéalement, une simulation réaliste du modèle de transport devrait être associée à un mo-
dèle hydrodynamique des courants marins : le modèle prend en entrée un champ de vélocité
des courants, et celui-ci devrait être calculé par un solveur hydrodynamique. Dans notre cas,
on supposera que ce champ de vélocité est constant ; on parlera ainsi de cours d’eau plutôt que
de mers ou d’océans.

1.1.1 Description mathématique du modèle de transport


Le processus de déplacement des polluants, du sel, etc., ainsi que leurs interactions bio-
chimiques, peuvent être formulées comme un système d’équations d’advection-diffusion-réac-
tion. On a alors un ensemble de conditions initiales et un systèmes d’équations aux dérivées
partielles non linéaires, les non-linéarités provenant uniquement des réactions bio-chimiques
entre les différentes espèces considérées.
Un système d’équations d’advection-diffusion-réaction 3D a la forme suivante :
∂c
+ A(c, a) = D(c, d ) + R(c, t ) (1.1)
∂t
où c est le vecteur (inconnu) des concentrations des différentes espèces, A(c, a) est le vecteur
définissant l’advection, et D(c, d ) est le vecteur définissant la diffusion. a est le champ des vé-
locités locales du fluide, et d est la matrice des coefficients de diffusion ; ces deux données sont
supposées être connues d’avance (a est par exemple calculé préalablement par un modèle hy-
drodynamique pour être plus fidèle à la réalité). Enfin, R(c, t ) représente les réactions entre les
espèces.

Si l’on considère un problème à trois dimensions spatiales et réduit à deux espèces chi-
miques, on montre que l’équation (1.1) est équivalente au système de deux EDP suivant :
∂c 1
à ! µ ¶ µ ¶ µ ¶
∂t + ∇c 1 × a = ∇ · ((∇c 1 ) × d ) + R 1 (c, t ) (1.2)
∂c 2 ∇c 2 × a ∇ · ((∇c 2 ) × d ) R 2 (c, t )
∂t
8 1. Présentation du problème

Le couplage entre les deux équations vient alors du terme de réaction R.

1.1.2 Modèle de calcul


Pour pouvoir effectuer la simulation, il est nécessaire d’établir un modèle de calcul à partir
du modèle mathématique décrit précédemment.

Dans un premier temps, l’équation (1.2) est discrétisée dans le domaine temporel (méthode
d’Euler) et dans le domaine spatial (différences finies du second ordre). On obtient ainsi un
système d’équations aux dérivées ordinaires :
dU (t )
= f (U (t ), t ) (1.3)
dt
où U est le vecteur contenant le maillage de points utilisé pour le calcul et f une fonction non
linéaire.
Comme on utilise une intégration temporelle implicite, le système aux EDO précédent de-
vient :
U (t ) −U (t − h)
= f (U (t ), t ) (1.4)
h
où h est un pas de temps fixé. On peut alors réécrire cette équation :
g (U (t ),U (t − h), t ) = U (t − h) −U (t ) + h f (U (t ), t ) (1.5)
et le problème consiste maintenant à résoudre g (U (t ),U (t − h), t ) = 0, ou, en simplifiant l’écri-
ture, g (U (t ),C (t − h)) = 0.
En utilisant la méthode de Newton, on aboutit à un schéma de calcul itératif :
³ ´ ³ ´
U k+1 (t ) = U k (t ) − J −1 U k (t ) g U k (t ),C (t − h) (1.6)

où J U k (t ) est la matrice jacobienne de g U k (t ),C (t − h) . On peut reformuler cette équation :


¡ ¢ ¡ ¢
³ ´³ ´ ³ ´
− J U k (t ) U k+1 −U k = g U k (t ),C (t − h) (1.7)
ce qui est équivalent à : ³ ´
U k+1 (t ) = F U k (t ) (1.8)

Résoudre cette équation revient à résoudre un système linéaire à chaque itération. On peut
de plus noter que la matrice jacobienne est creuse, et que ses termes non-nuls sont répartis sur
plusieurs diagonales.
Néanmoins, le calcul de la matrice jacobienne elle-même est très complexe. On préférera
donc utiliser la méthode quasi-Newton : la matrice jacobienne n’est calculée qu’à la première
itération, et est réutilisée telle quelle ensuite. Ceci permet de réduire le temps de calcul, mais en
contrepartie le nombre d’itérations risque d’être beaucoup plus important qu’avec la méthode
de Newton classique.

Pour paralléliser le calcul, on utilisera la méthode multisplitting Newton. Cette méthode est
assez similaire aux techniques de décomposition par blocs : le domaine initial est découpé en
plusieurs sous-domaines, et chacun est assigné à un processeur différent.
Dans notre cas, il faut donc résourdre l’équation (1.7) à chaque itération. Pour simplifier les
notations, on la notera ainsi :
− J × δU = g (1.9)
Dans le cadre de la méthode de multisplitting, on découpe la matrice jacobienne en blocs,
et δU et g sont décomposés de manière similaire et compatible. Chaque processeur travaille
alors sur un système plus petit, comme représenté sur la figure 1.1.
Cette méthode, et tout ce problème en général, sont décrits plus en détail dans [BCMS06]
et [BCVC07].
1. Présentation du problème 9

F IGURE 1.1 – Décomposition de la matrice jacobienne, du vecteur solution et de la fonction

1.2 Algorithmes synchrones et asynchrones


Les algorithme itératifs séquentiels sont très utilisés en calcul scientifiques. Ils sont sou-
vent plus simples à implémenter que les algorithmes directs, tout en gardant de bonnes per-
formances. Pour ce qui est des solveurs linéaires creux, ils nécessitent clairement moins de
mémoire que les algorithmes directs, ce qui les rend particulièrement avantageux sur GPU.

Dans les algorithmes itératifs parallèles, les données et routines de calcul sont réparties sur
plusieurs processeurs. Ces algorithmes peuvent être divisés en deux grandes catégories : les
algorithmes synchrones et les algorithmes asynchrones.

Les algorithmes synchrones sont les plus connus, et actuellement quasiment les seuls à
être utilisés dans les milieux scientifiques. Le principe est de synchroniser chaque itération
en échangeant des données à la fin des itérations. Si les communications sont synchrones,
on parle d’algorithme SISC (Synchronous Iterations with Synchronous Communications). Si les
communications sont asynchrones, on parlera de la même manière d’algorithmes SIAC (Syn-
chronous Iterations with Asynchronous Communications).
Le déroulement d’un algorithme SISC est représenté sur la figure 1.2. On voit qu’il y a des
périodes d’inactivité dues aux échanges de données et aux calculs liés à la détections de conver-
gence (les communications sont représentées par des flèches). Les algorithmes SIAC sont un
peu plus rapides car les communications tendent à être masquées par les calculs, mais les pé-
riodes d’inactivité ne sont pas totalement supprimées pour autant.
L’avantage de ces algorithmes synchrones parallèles est que la convergence est exactement
la même que celle de leurs pendants séquentiels.

F IGURE 1.2 – Déroulement d’un algorithme SISC

La classe d’algorithmes itératifs parallèles la plus générale est la classe de ceux dont les
10 1. Présentation du problème

itérations et les communications sont asynchrones. On parle alors d’algorithmes AIAC (Asyn-
chronous Iterations with Asynchronous Communications). Les algorithmes SISC et SIAC sont
alors des cas particuliers d’algorithmes AIAC.
Le déroulement d’un algorithme AIAC est représenté sur la figure 1.3. On constate que les
temps d’attente sont complètement supprimés : les processeurs envoient leurs données aux
autres dès qu’ils ont fini une itération et si, lorsqu’une itération est terminée, un processeur
n’a pas encore reçu de nouvelles données, il recommence avec les mêmes données plutôt que
d’attendre que les autres processeurs soient prêts. Avec cette méthode, le nombre d’itérations
est généralement plus important, mais comme on évite les pertes de temps dues aux synchro-
nisations, ces algorithmes sont souvent plus rapides que leurs équivalents synchrones (mais ce
n’est pas toujours le cas). On peut encore distinguer plusieurs sous-catégories d’algorithmes
AIAC (selon la manière dont on effectue les communications), mais ceci dépasse le cadre de ce
rapport.
La convergence des algorithmes AIAC est différente de celle des algorithmes séquentiels
équivalents : les opérateurs appliqués pendant les itérations doivent être contractants pour
être certain que le processus converge bien vers la solution du système.

F IGURE 1.3 – Déroulement d’un algorithme AIAC

Les algorithmes asynchrones, leur formalisme et leur implémentation sont décrits plus en
détails dans [BCMS06] et [BCVC07].

1.3 Intérêt des GPU


Les GPU 1 sont les composants qui forment le cœur des cartes graphiques présentes dans
tous les ordinateurs portables ou de bureau récents. Il s’agit de processeurs spécialisés dans
le calcul graphique, et ils sont utilisés principalement pour les jeux vidéos. Des cartes dites
« professionnelles » sont également disponibles, et sont plutôt dédiées au rendu de scènes 3D
complexes (effets spéciaux ou films d’animation) ou de visualisation scientifique.
Ces processeurs sont cependant très intéressants pour le calcul scientifique à hautes per-
formances : ils fonctionnent à des fréquences d’horloge relativement faibles (moins d’1 GHz),
mais de manière massivement parallèle.
Les GPU sont à la base destinés au rendu 3D et visent principalement les jeux vidéo. Ce-
pendant, les constructeurs NVIDIA et ATI développent depuis plusieurs mois des toolkits per-
mettant d’utiliser ces processeurs graphiques à des fins généralistes : on parle alors de GPGPU
(General Purpose GPU). Ces GPGPU, grâce aux API CUDA chez NVIDIA et Stream chez ATI,
peuvent alors être utilisés pour de nombreuses applications, dont le calcul intensif.
Nous nous intéresserons ici uniquement à l’architecture CUDA de NVIDIA car ce sont les
seules dont nous disposons à l’heure actuelle.

1. Graphics Processing Unit


1. Présentation du problème 11

1.3.1 Architecture matérielle


CUDA représente une abstraction du matériel réel de la carte graphique. On a alors accès
au matériel suivant (cf. Figure 1.4) :
– la RAM globale de la carte GPU (device memory) ;
– une mémoire de textures ;
– une mémoire de constantes ;
– plusieurs multi-processeurs équipés chacun d’une zone de mémoire partagée (shared
memory) et de registres.

F IGURE 1.4 – Matériel présent sur une carte GPU

Sur le cluster de GPU de Supélec, chaque carte (de type GeForce 8800 GT) compte 14 multi-
processeurs (MP), et chaque multi-processeur compte 16 ko de mémoire partagée et 8 192 re-
gistres.

Chaque MP compte 8 processeurs scalaires (SP) et un scheduler multithreads. Le MP peut


ainsi exécuter de nombreux threads en parallèles : 24 warps comptant chacun 32 threads. Ainsi,
un MP peut exécuter 768 threads en parallèle.
12 1. Présentation du problème

Les threads sont regroupés en blocs (auquel on peut donner une topologie 1D, 2D ou 3D).
Chaque bloc compte au plus 512 threads, et chaque bloc s’exécute sur un et un seul MP. Ainsi,
il est conseillé d’utiliser des blocs dont le nombre de threads :
– est multiple de 32, afin d’utiliser des warps complets ;
– divise 768, afin de pouvoir utiliser 100% d’un MP.
Dans la pratique, on utilise souvent des blocs de 256 threads. Chaque MP peut alors exécuter 3
blocs en parallèle, ce qui permet (si les threads n’utilisent pas trop de registres et de mémoire
partagée) d’utiliser pleinement ses capacités de calcul.

Les blocs sont ensuite regroupés dans des grilles, auquel on peut aussi donner une topolo-
gie 1D, 2D ou 3D. Les tailles limites des grilles sont beaucoup plus élevées (65 536 dans chaque
dimension).

Depuis tout thread, on peut accéder à la dimension de la grille et du bloc parent, ainsi qu’à
l’indice du bloc dans la grille et à l’indice du thread dans le bloc. On peut ainsi construire pour
chaque thread un identifiant unique, qui sert souvent à calculer l’emplacement mémoire où le
thread va travailler.
Cette hiérarchie thread-bloc-grille est représentée sur la Figure 1.5.

1.3.2 Aspects logiciels


La programmation des GPU se fait dans un langage spécifique à CUDA et qui est une ex-
tension du langage C. Il faut alors distinguer le code s’exécutant sur le CPU (host) et celui s’exé-
cutant sur le GPU (device). Le code destiné au GPU est groupé dans des fonctions appelées
kernels : lorsque le CPU lance un kernel, le code correspondant est envoyé au GPU, qui lance
alors le calcul d’une grille avec les dimensions précisées lors de l’appel.

L’API CUDA permet de gérer tous les aspects de la programmation sur GPU :
– détection et initialisation des cartes GPU ;
– allocation de mémoire dans la RAM GPU ;
– transferts de données bidirectionnels entre RAM globale et RAM GPU ;
– lancement de kernels et attente de terminaison des exécutions asynchrones ;
– gestion des erreurs ;
– compatibilité avec les API graphiques OpenGL et Direct3D...
Il existe en fait 2 API, l’une de bas niveau (driver API) et l’autre de haut niveau (runtime API).
Elles sont mutuellement exclusives. Sauf cas particuliers, il vaut mieux utiliser l’API de haut
niveau, plus simple et fiable que l’autre.

Les API CUDA sont constituées de fonctions C/C++ standard, et peuvent être compilées
avec n’importe quel compilateur C/C++. Par contre, les kernels sont très différents : ils ne peu-
vent contenir qu’un sous-ensemble du C auquel s’ajoute un nombre fini de fonctions mathé-
matiques. Ce code doit de plus être compilé dans un format spécifique aux GPU. Il doit donc
être écrit dans des fichiers .cu et compilé avec nvcc, le compilateur dédié fourni par NVIDIA.
Normalement, les fichiers objets générés par nvcc peuvent être liés à ceux de gcc (par
exemple), de sorte que l’on peut appeler des fonctions C définies dans un fichier .cu depuis
n’importe quelle partie d’un programme C/C++. En pratique il y a cependant des problèmes :
nvcc est un « driver de compilateur » qui s’appuie sur le compilateur natif du système (gcc,
msvc...), mais avec des limitations assez fortes, notamment au niveau de C++ et des templates.

L’optimisation d’un programme CUDA peut être assez compliquée. En effet, contrairement
aux CPU, les GPU n’incluent pas de mécanisme de cache mémoire. Il faut donc être particu-
lièrement attentif aux accès mémoire, et notamment aux accès à la RAM qui est beaucoup
1. Présentation du problème 13

F IGURE 1.5 – Threads, blocs et grilles


14 1. Présentation du problème

plus lente que la mémoire partagée ou que les registres d’un MP (d’un facteur variant pou-
vant atteindre 100). Il faut en particulier veiller à faire des accès alignés à la mémoire, à éviter
les conflits de banque, à ne faire que des accès ordonnés, etc.

Pour plus de détails, il est plus que recommandé de consulter la documentation officielle
de CUDA [NVI08], très complète et très bien conçue.
2. Solveur linéaire sur GPU 15

Chapitre 2

Solveur linéaire sur GPU

La première étape du stage a été l’élaboration d’un solveur linéaire sur GPU, qui constitue
l’élément central du problème traité. Dans cette partie, nous décrivons ce qui existait déjà dans
ce domaine ; puis nous explicitons les choix qui ont été faits lors de la conception du solveur,
ainsi que leurs motivations ; enfin, nous évaluons les performances du solveur afin de pouvoir
conclure sur son efficacité.

2.1 Ressources disponibles pour l’algèbre linéaire sur GPU


À la date du début de ce stage, il existe plusieurs bibliothèques d’algèbre linéaire sur GPU.
Nous les avons donc évaluées pour déterminer si elles peuvent correspondre à notre besoin.

2.1.1 Opérations matricielles avec CUBLAS


BLAS (Basic Linear Algebra Subprograms) est une API standardisée pour réaliser des opéra-
tions d’algèbre linéaire. Les développeurs de NVIDIA en ont fait une implémentation partielle
sur GPU : la librairie CUBLAS, distribuée en standard avec CUDA.
BLAS se divise en 3 niveaux :
– opérations vecteur-vecteur (niveau 1) ;
– opérations matrice-vecteur (niveau 2) ;
– opérations matrice-matrice (niveau 3).
Ces opérations sont cependant assez limitées. Pour résumer, au niveau 1 il y a les opéra-
tions du type y ← αx + y, les produits scalaires, et les recherches de minimum/maximum en
valeur absolue dans un vecteur ; au niveau 2 on trouve principalement des opérations de type
y ← αAx + βy, et au niveau 3 des opérations C ← αAB + γC .
Pour les résolutions de systèmes linéaires, il existe une méthode de niveau 2 appelée DTRSV :
elle peut résoudre Ax = b. Mais DTRSV signifie Double TRiangular SolVe : cette méthode ne
fonctionne qu’avec des matrices triangulaires.
Pour des cas plus génériques, il faut donc utiliser autre chose. BLAS ne sert que de « brique
de base » à la construction d’algorithmes plus compliqués.

Au niveau des performances, il y a quelques soucis avec CUBLAS. Les opérations les plus
utilisées (produit de matrices denses...) ou les plus simples (produit scalaire...) sont très ra-
pides, d’autres le sont beaucoup moins. Il semble que la dimension des données est également
très importante : un padding pour atteindre un multiple de 32 peut être très intéressant [QO08].
Néanmoins, au fur et à mesure de la publication par NVIDIA de nouvelles versions de CUDA
et de CUBLAS, les choses vont en s’améliorant : les performances sont régulièrement revues à
la hausse. CUBLAS reste donc une solution intéressante pour faire rapidement des choses qui
fonctionnent ; l’optimisation manuelle du code peut être une étape ultérieure, à n’effectuer que
quand on est sûr de disposer d’un algorithme qui fonctionne.
16 2. Solveur linéaire sur GPU

2.1.2 Solveurs linéaires existants


Sur CPU, on trouve différentes implémentations de LAPACK, un package qui s’appuie sur
BLAS pour fournir des possibilités de calcul à plus haut niveau. LAPACK propose par exemple
la méthode DGESV qui permet de résoudre des systèmes denses du type Ax = b.
Sur GPU, il n’y a pas d’implémentation complète publique de LAPACK. Il y a cependant
quelques librairies qui offrent des pistes plus ou moins intéressantes.

MAGMA
MAGMA est un projet mené par l’université du Tennessee en collaboration avec les univer-
sités de Berkeley, du Colorado, et de Coimbra (Portugal) 1 .
Selon leur site : The MAGMA project aims to develop a dense linear algebra library similar
to LAPACK but for heterogeneous/hybrid architectures, starting with current "Multicore+GPU"
systems. Il s’agit donc d’utiliser à la fois le CPU et le GPU pour améliorer les performances
[DMP+ 08]. Cependant le code n’est pas disponible publiquement.

cudaztec
cudaztec est un projet mené par David Neckels, ingénieur chez Beckam Coulter, Colorado.
Le projet est décrit ainsi sur sa page d’accueil : Iterative solvers and preconditioners for the
GPU 2 . A priori c’est donc très intéressant... Mais dans la pratique, il n’y a pas encore grand
chose d’utilisable.
Pour le moment, un solveur utilisant l’algorithme GMRES est implémenté en utilisant CU-
BLAS et une implémentation externe de SPMV (Sparse Matrix-Vector Multiplication). L’auteur
note cependant que le calcul est plus lent sur le GPU que sur le CPU ; selon lui c’est parce que
CUBLAS est plus lent, mais les explications qu’il donne sont un peu douteuses : CUBLAS ferait
des transferts CPU/GPU à chaque opération. Dans la mesure où il y a des méthodes spécifiques
pour les transferts (cublasGetMatrix, cublasSetMatrix, cublasGetVector,
cublasSetVector), ce n’est pas très crédible : il semble plus probable que les méthodes utili-
sées par l’auteur ne soient pas très adaptées au GPU.
La compilation de l’exemple proposé est également problématique (sous Linux avec le SDK
CUDA 2.1 ; l’auteur travaille sous Windows, visiblement avec une version plus ancienne du
SDK).

GPUmatrix
GPUmatrix 3 est un projet visant à implémenter LAPACK sur GPU mené par Nicolas Bon-
neel, doctorant dans l’équipe REVES de l’INRIA Sophia-Antipolis.
Dans son état actuel, cette bibliothèque implémente les décompositions LU, QR, et de Cho-
lesky. Le code est assez fruste, peu commenté, visiblement non maintenu ; là encore il est prévu
pour compiler avec msvc mais ne compile pas avec gcc.

FLAME
FLAME (Formal Linear Algebra Method Environment) 4 est un projet très ambitieux visant
à créer un système permettant, à partir d’une description d’algorithme d’algèbre linéaire, de
générer un code optimal implémentant cet algorithme. Il s’agit notamment de déduire auto-
matiquement les dépendances entre les différentes opérations (basées sur BLAS) à effectuer

1. http://icl.cs.utk.edu/magma/index.html
2. http://code.google.com/p/cudaztec/
3. http://sourceforge.net/projects/gpumatrix
4. http://www.cs.utexas.edu/users/flame/index.html
2. Solveur linéaire sur GPU 17

et d’utiliser un ordonnanceur adapté (et prévu pour un environnement multi-threadé) pour


traiter au mieux les données.
Divers messages sur le forum de NVIDIA indiquent que des chercheurs ont utilisé FLAME
en combinaison avec CUBLAS, apparemment avec de bons résultats. Mais visiblement il n’y a
aucun code disponible pour voir ce que ça donne en pratique. De plus, pour une utilisation
« simple », FLAME est peut-être trop compliqué.

CNC
CNC (Conccurent Number Cruncher) 5 est une implémentation sur GPU d’un general sparse
linear solver, développé par l’équipe ALICE du LORIA [BCLar]. Le code de cette librairie est
disponible sur le site du LORIA sous licence GPL.
Ses performances sont apparemment très bonnes : l’article le présentant indique que les
opérations de BLAS sur GPU sont entre 4 et 18 fois plus rapides que leurs équivalents sur CPU
(avec la librairie MKL d’Intel, très optimisée pour les processeurs récents avec SSE3). Mais s’il
est bien adapté aux matrices creuses peu ou pas structurées, il faudrait voir ce qu’il en est pour
des matrices structurées (diagonales) comme celle que l’on aura besoin de traiter. En effet,
Bruno Lévy, directeur de l’équipe ALICE, nous a fortement conseillé d’exploiter la structure des
matrices que nous utilisons, et donc d’utiliser autre chose que ce solveur.
Le code source disponible est prévu pour MS Visual Studio, il faudrait donc faire quelques
petites adaptations (peut-être simplement un Makefile ?) pour arriver à le compiler sous Linux.

Il est à noter que CNC est l’un des solveurs creux les plus connus dans la communauté des
développeurs CUDA, car il est l’un des premiers à bien fonctionner, et NVIDIA l’indique dans
sa liste des meilleurs projets utilisant CUDA. Il serait donc très intéressant pour nous d’envi-
sager d’intégrer dans CNC le solveur linéaire adapté aux matrices diagonales que nous avons
développé ; ceci n’est encore pour le moment qu’à l’état de projet.

2.2 Contraintes pour une nouvelle implémentation


Les différentes solutions existantes ne nous ont pas paru satisfaisantes. Nous avons donc
décidé de créer un nouveau solveur linéaire, mieux adapté à nos besoins spécifiques.

Pour réaliser un solveur, on a le choix entre deux classes d’algorithmes : les méthodes di-
rectes et les méthodes itératives. Les premières sont potentiellement plus précises (car plus
exactes), mais plus lentes ; les secondes ont l’avantage d’être nettement plus rapides et généra-
lement moins coûteuses en mémoire. Il s’agit donc principalement d’un compromis vitesse/-
précision.

Cependant les GPU présentent une contrainte forte au niveau de la précision : les cartes à
notre disposition ne gèrent que le calcul sur des nombres flottants en simple précision (type
float, codé sur 32 bits). Alors que les flottants double précision (64 bits) sont parfaitement gé-
néralisés sur CPU et que beaucoup de monde envisage de passer à 128 bits pour encore gagner
en précision, les GPU « bas de gamme » sont donc nettement désavantagés.
De plus, l’implémentation des nombres flottants n’est pas totalement conforme à la norme
IEEE : si l’addition et la multiplication se font sans erreur, il y a une erreur d’arrondi lors des
divisions et des autres opérations (racine carrée, fonctions trigonométriques, etc.). Si l’on uti-
lise un algorithme utilisant la division, il y a alors un risque élevé de propagation de l’erreur
d’arrondi : des essais effectués par Stéphane Vialle indiquent que l’erreur relative par rapport

5. http://alice.loria.fr/index.php/publications.html?redirect=1&Paper=CNC@2008
18 2. Solveur linéaire sur GPU

aux calculs sur CPU est couramment de 10−6 mais peut facilement monter jusqu’à 10−4 voire
10−3 , ce qui est énorme.
La sensibilité aux erreurs d’arrondis est donc essentielle dans le choix de l’algorithme à
utiliser.

2.2.1 Méthodes directes


Contrairement aux méthodes itératives, les méthodes directes visent à résoudre un pro-
blème en un nombre fini d’opérations et, en l’absence d’erreur d’arrondis, à trouver une solu-
tion exacte.

Souvent ces méthodes nécessitent plus de mémoire que les méthodes itératives. Il convient
donc de les utiliser avec prudence.

Décomposition QR

La décomposition QR est l’une des méthodes permettant de résoudre facilement un sys-


tème du type Ax = b.
L’idée de base est de décomposer la matrice A en Q et R telles que A = QR où Q est une ma-
trice orthogonale (Q T = Q −1 ) et R une matrice triangulaire supérieure. Une fois ceci effectué,
on peut alors facilement calculer x :

Ax = QR x = b ⇐⇒ R x = Q −1 b = Q T b

R étant triangulaire, Rx = Q T b se calcule très facilement (méthode BLAS appropriée par exem-
ple).
On voit cependant qu’il faut stocker deux matrices à la place d’une seule : cette méthode
est donc très gourmande en mémoire. Pour des systèmes de taille importante, ceci peut rendre
la méthode difficilement utilisable, et donner un avantage à des méthodes itératives.

La partie difficile de cette méthode est le calcul de Q et R. Il existe au moins trois méthodes
permettant de les trouver [Wik09] [Des06] :
– méthode de Gram-Schmidt : relativement sensible aux erreurs d’arrondis ;
– rotations de Givens : facilement parallélisable, mais nécessite beaucoup de produits de
matrices, d’accès mémoire et de calculs trigonométriques (peu précis) ;
– méthode de Householder : la plus stable numériquement.

De manière générale, la méthode de Householder est la plus utilisée. Elle a une complexité
en O (n 3 ), mais est connue pour être celle qui résiste le mieux aux matrices mal conditionnées,
car elle limite le nombre de divisions par des petits nombres.

Il existe de plus de nombreux travaux de parallélisation de la décomposition QR, y compris


sur GPU [VD08]. Cette méthode semble donc bien adaptée à l’élaboration d’un solveur linéaire
en CUDA. Néanmoins elle est assez lourde à mettre en place, et ce d’aurant plus que nous nous
intéressons à des systèmes creux, utilisant donc des schémas de stockage adaptés, où la plus
grande partie des valeurs est définie implicitement à zéro. Il y a alors un risque très fort d’avoir
des divergences de branche au sein d’un même warp (un thread prend une branche d’un if, un
autre part dans un else), ce qui est très coûteux en termes de performances sur une machine
SIMD telle qu’un GPU.
Ainsi, cette méthode, bien que séduisante, nécessiterait beaucoup de travail pour être effi-
cace dans notre cas.
2. Solveur linéaire sur GPU 19

2.2.2 Méthodes itératives


Il existe de nombreux algorithmes itératifs permettant de résoudre ce problème : Jacobi,
Gauss-Seidel, Successive Over-Relaxation, GMRES, gradient biconjugué... Celles-ci sont no-
tamment décrites dans [BCVC07]. Il y a donc plusieurs facteurs à prendre en compte :
– vitesse de convergence : on préférera bien sûr les méthodes les plus rapides (en temps,
pas forcément en nombre d’itérations) ;
– occupation mémoire : si on travaille sur de très grosses matrices, mieux vaut éviter les
algorithmes qui ont besoin de plusieurs matrices intermédiaires ;
– parallélisme : CUBLAS se charge du plus gros du parallélisme, mais peut-être qu’il y a
quand même certains aspects à approfondir (accès mémoires, dépendances entre les
données d’une itération sur l’autre...) ;
– simplicité de mise en œuvre : il faut que le système soit fonctionnel dans un intervalle
de temps relativement court, aussi il vaut mieux choisir une méthode simple à mettre
en œuvre plutôt qu’une méthode plus compliquée (les éventuels temps de debug sont
probablement plus longs que ce qu’on perd avec un algorithme un peu moins efficace).

Sur le principe, toutes les méthodes itératives fonctionnent de la même manière : pour ré-
soudre un système Ax = b, on construit une suite de vecteur (x k )k∈N qui converge vers x. À par-
tir d’une hypothèse initiale x 0 , on calcule donc successivement les différents termes de la suite,
données par une fonction de type x k+1 = f (x k , A, b), et on s’arrête lorsque l’erreur kb − Axk est
suffisament faible (ou que l’on a atteint un nombre maximum d’itérations, ou que l’erreur de-
vient stable et ne diminue plus...).

A priori, les méthodes de Jacobi et de Gauss-Seidel semblent être bien adaptées. Elles sont
bien connues, très documentées, et efficaces ; elles sont de plus relativement simples à mettre
en œuvre, aussi bien sur CPU que sur GPU.
Pour chercher à avoir de meilleures conditions de convergence, nous nous sommes égale-
ment intéressés à l’algorithme du gradient biconjugué. Celui-ci reste également relativement
simple à mettre en œuvre, mais l’on dispose de moins de résultats théoriques quant à ses per-
formances ou ses conditions de convergence.

Algorithmes de Jacobi et de Gauss-Seidel


L’algorithme de Jacobi est l’un des algorithmes itératifs les plus simples pour résoudre un
système linéaire Ax = b. Il consiste à décomposer la matrice A en A = M − N avec M inversible,
puis à construire la suite (x k ) avec :
³ ´
x k+1 = M −1 N x k + b

En pratique, on prend pour M la diagonale de A. On montre alors que x k+1 peut se calculer
composante par composante :
à !
1
x ik+1 = a i j x kj
X
bi −
ai i j 6=i

De manière théorique, on sait que cet algorithme converge si et seulement si le rayon spectral
de la matrice M −1 N est strictement inférieur à 1. En pratique, il converge pour toute valeur
initiale si la matrice A est à diagonale strictement dominante [BCVC07].

L’algorithme de Gauss-Seidel est une variante de la méthode de Jacobi (qui améliore la vi-
tesse et les conditions de convergence). Pour un système Ax = b, on construit une suite de
vecteurs (x k ) telle que :
(D − L)x k+1 = Ux k + b
20 2. Solveur linéaire sur GPU

où D est la partie diagonale de A, −L sa partie triangulaire inférieure et −U sa partie triangulaire


supérieure (on a donc A = D − L −U ). On montre alors qu’à chaque itération, on peut calculer
la i -ème composante de x k+1 par la formule :
à !
1 iX−1 n
x ik+1 k+1 k
X
= bi − ai j x j − ai j x j
ai i j =1 j =i +1

L’implémentation CPU de l’algorithme de Gauss-Seidel est directe : on peut travailler direc-


tement sur x et le mettre entièrement à jour à chaque itération en parcourant ses composantes
dans l’ordre.
Sur GPU, il est plus simple d’utiliser la méthode de Jacobi : chaque thread ne met à jour
qu’un seul point du vecteur x à partir des valeurs présentes en mémoire globale. Ce vecteur
est ensuite mis à jour par tous les threads en même temps avec la nouvelle valeur qu’ils ont
calculée.

Ces méthodes présentent néanmoins un défaut : à chaque itération, on effectue une divi-
sion par un terme diagonal (terme en a1i i ). Sur GPU, il y a donc un risque de perte de précision
à cet endroit-là.
Cependant les termes diagonaux de la matrice A n’interviennent que dans le cadre de ces
divisions, et nulle part ailleurs. On peut donc envisager de prétraiter la matrice sur le CPU en
remplaçant tous les termes diagonaux par leurs inverses, de transférer cette matrice modifiée
sur le GPU, et d’effectuer ensuite des produits en lieu et place des divisions : les multiplications
sur GPU étant conformes à la norme IEEE, on élimine ainsi la perte de précision.

Algorithme du gradient biconjugué


L’algorithme du gradient biconjugué (abrégé en BiCG) est une extension de l’algorithme
classique du gradient conjugué qui permet de l’utiliser sur des matrices non symétriques. On
peut voir celui-ci comme une descente de gradient où, à chaque étape, on calcule la meilleure
direction vers laquelle descendre plutôt que d’utiliser simplement le gradient en un point.
BiCG est réputé rapide et efficace, et dans la plupart des cas il converge mieux que les
algorithmes de Jacobi ou de Gauss-Seidel. Néanmoins, il y a peu de résultats théoriques qui
viennent confirmer ceci.
L’un des défauts de cet algorithme est qu’il requiert plusieurs vecteurs intermédiaires au
cours du calcul d’une itération (6 vecteurs de dimension n pour un système de taille n). Il est
donc plus coûteux en terme de mémoire que Jacobi et Gauss-Seidel.
Pour notre problème, nous avons implémenté l’algorithme du gradient biconjugué sans
préconditionneur tel que décrit dans [BBC+ 94] (cf. Algorithme 1).

2.2.3 Amélioration de la précision


Quelle que soit la méthode utilisée, la résolution de Ax = b n’est pas parfaite : si x est la so-
lution exacte du système, les différents algorithmes à notre disposition permettent de calculer
x̂, approximation de x à δx près.
On peut donc chercher à améliorer la précision en essayant de déterminer ce δx.
En effet, si Ax = b, alors A (x̂ + δx) = b, d’où :

Aδx = b − A x̂

Ainsi, avec le même algorithme, on peut calculer le résidu δx et l’utiliser pour améliorer la pré-
cision obtenue sur x̂.
2. Solveur linéaire sur GPU 21

Algorithme 1 Algorithme du gradient biconjugué


1: Calculer r 0 ← b − Ax 0 pour une solution initiale x 0
2: Calculer r˜0 (par exemple r˜0 ← r 0 )
3: for i = 1, 2, ... do
4: ρ i −1 ← r i −1 · r˜i −1
5: if ρ i −1 = 0, échec
6: if i = 1 then
7: p i ← r i −1
8: p̃ i ← r˜i −1
9: else
ρ
10: β ← ρ ii −1
−2

11: p i ← r i −1 + βp i −1
12: p̃ i ← r˜i −1 + βp̃ i −1
13: end if
14: q i ← Ap i
15: q̃ i ← A T p̃ i
ρ −1
16: α ← p̃ ii·q i

17: x i ← x i −1 + αp i
18: r i ← r i −1 − αq i
19: r˜i ← r˜i −1 − αq̃ i
20: détection de convergence
21: end for

Cette méthode simple n’est pas à négliger lors de la finalisation du solveur. La méthode QR
se prête de plus particulièrement bien à ce calcul de résidu : comme A ne change pas, il n’est
pas nécessaire de recalculer Q et R ; le calcul de δx peut alors être très rapide.

On peut aussi utiliser des méthodes relevant plutôt d’astuces de programmation pour amé-
liorer la précision des calculs. Ainsi, lorsque l’on effectue des calculs sur des nombres flottants
sur 32 bits, on sait que l’on risque de perdre les bits de poids faible, et donc de perdre de la
précision. On peut alors envisager d’utiliser un second nombre flottant servant à compenser
cette perte de précision.
Cette approche a été utilisée il y a plusieurs années pour apporter une précision proche de
64 bits aux machines 32 bits uniquement grâce à un module Fortran nommé DSFUN90 [Bai05].
Ce module a récemment été partiellement porté en CUDA par un chercheur de l’université de
Delft (Pays-Bas) qui a publié son code sur le forum de discussion de NVIDIA 6 . Il n’y a cependant
pas d’évaluation rigoureuse et complète de l’efficacité réelle de cette méthode sur les perfor-
mances et la précision du calcul ; elle peut cependant être intéressante à utiliser dans le cas où
les méthodes de calcul « simples » ne permettraient pas d’atteindre une précision suffisante.

Il faut enfin noter qu’il y a un biais naturel entre les calculs en flottants 32 bits sur CPU par
rapport à ceux sur GPU : les floating point units (FPU) intégrés aux CPU modernes travaillent
sur des registres de 80 bits. Ainsi, si l’on applique différentes opérations à un flottant 32 bits,
celui-ci sera traité en interne sur 80 bits, avec donc une très grande précision, avant d’être en-
suite tronqué à 32 bits pour le stockage en mémoire.
Cette précision plus importantes dans les calculs peut faire une différence non négligeable
sur les résultats finaux des différents algorithmes, aussi nous avons décidé de désactiver ces
capacités de calcul sur 80 bits (à l’aide des méthodes de l’en-tête C standard fpu_control.h)
afin de tout ramener à 32 bits.
6. http://forums.nvidia.com/index.php?showtopic=73067&mode=linear
22 2. Solveur linéaire sur GPU

2.3 Implémentation et performances


Une fois les algorithmes et méthodes choisis, nous en avons implémenté plusieurs afin de
les tester et de comparer leurs performances.

2.3.1 Un premier solveur dense


Dans un premier temps, nous avons implémenté un solveur très simple pour matrices
denses afin d’avoir une référence pour le reste du développement. À cet effet, nous avons uti-
lisé la librairie Eigen2, une librairie C++ libre, bien conçue et bien documentée, qui intègre déjà
un solveur utilisant la décomposition LU (une méthode directe donc). Ainsi, la résolution d’un
système Ax = b se résume à x = A.lu().solve(b).
Ce solveur est une méthode directe, utilisant pleinement les capacités du CPU sur lequel il
a été compilé. Par conséquent, le résultat est très précis (la norme du vecteur d’erreur kb − Axk
est toujours inférieure à 10−12 , même en n’utilisant que des float)... mais le calcul est très lent
(plusieurs minutes pour un système de taille 10 000, et une consommation mémoire énorme).
Il nous a ainsi servi à élaborer les premiers solveurs itératifs, pour vérifier si leur fonctionne-
ment était correct, puis nous l’avons abandonné car il était trop lent – et pas du tout adapté
aux matrices creuses sur lesquelles nous avons travaillé.

2.3.2 Format de stockage des données


Les matrices auxquelles nous nous sommes intéressé sont des matrices creuses constituées
d’un faible nombre de diagonales non-vides. Ce sont donc des matrices bien structurées, et il
convient de bien exploiter cette structure afin de pouvoir à la fois les stocker efficacement et
les exploiter de manière optimale.
On travaille en effet sur des systèmes de grande taille (matrices n ×n avec n valant au mini-
mum 10 000 éléments et pouvant monter jusqu’à plusieurs centaines de milliers). Un stockage
dense de ces matrices serait donc désastreux pour plusieurs raisons :
– taille considérable en mémoire : une matrice dense 10 000 × 10 000 occupe 381 MB (en
utilisant un stockage avec des float de 32 bits) ; si cette matrice ne contient que 30 dia-
gonales, on peut réduire cette taille à 1,14 MB. On peut donc stocker des systèmes bien
plus grands : pour occuper 380 MB avec une matrice creuse comptant 30 diagonales, il
faudrait que ce soit une matrice 3 333 333 × 3 333 333...
– temps de calcul considérablement plus long : comme la quantité de données utilisées est
plus importante pour une matrice dense que pour sa représentation creuse, le nombre
de calculs effectués à chaque itération est d’autant plus grand, et donc l’algorithme est
d’autant plus lent. Ceci est encore accentué sur GPU par le fait que les accès mémoires
sont très lents par rapport aux calculs.
– imprécision : bien que théoriquement des multiplications par 0 et des additions de 0
ne modifient pas la valeur d’une variable, en pratique les implémentations des calculs
avec des nombres flottants sont imparfaites. Si l’on multiplie un grand nombre par 0,
il est possible que quelques bits de poids faibles soient modifiés, qu’ils soient ensuite
additionnés à une autre valeur et, une chose en amenant une autre, on accumule ainsi
des imprécisions qui peuvent au final complètement fausser le résultat d’un algorithme,
voire le faire diverger.
Il est donc indispensable d’utiliser un format de stockage creux, et de l’adapter au mieux à
nos besoins, c’est-à-dire ici à des matrices carrées composées uniquement de quelques diago-
nales.

Le format que nous avons retenu est similaire au format DIA tel que décrit dans [BG08].
Considérons par exemple une matrice creuse 10×10 (A = (a i j )), composée de 4 diagonales
non-vides (cf. Figure 2.1). Chaque diagonale est composée d’une famille d’éléments (a i j ) telle
2. Solveur linéaire sur GPU 23

que k = j − i est une constante. Cette constante, l’indice de la diagonale, permet donc de re-
trouver j si l’on connaît i . Par convention, on la donnera toujours modulo n (avec n la taille du
système).
Si l’on étend cette définition à tous les a i j vérifiant j − i = k et k 6= 0, la diagonale s’étend
alors à des valeurs situées au-dessus et en-dessous de la diagonale principale : on identifiera
alors tous les termes de cette « diagonale étendue » grâce à i et k.

01 4 8

F IGURE 2.1 – Matrice creuse 10 × 10 composée de 4 diagonales

On peut alors proposer un mode de stockage très efficace pour cette matrice :
– un tableau n × d pour stocker les d diagonales en colonne ;
– une liste de d entiers indiquant à quelle indice k correspond chaque colonne du tableau.
La matrice A donnée précédemment sera ainsi stockée comme représenté sur la Figure 2.2.

01 48

F IGURE 2.2 – Représentation en mémoire de la matrice précédente

Ainsi, là où un stockage dense nécessitait n 2 flottants, on n’utilise ici que n · d flottants et d


entiers ; ce format est donc très efficace lorsque n est grand devant d , ce qui est généralement
le cas.

2.3.3 Algorithme de Jacobi/Gauss-Seidel


L’implémentation, à la fois sur CPU et GPU, est assez simple et directe.

Nous avons surtout constaté que le format de stockage de données utilisé est très adapté
à ces algorithmes sur GPU : il est possible de faire des accès mémoires séquentiels entre les
différents threads d’un même bloc, ce qui est un facteur extrêmement important pour les per-
formances du calcul sur GPU.
Pour encore améliorer les choses, nous avons modifié quelques détails sur le schéma de
stockage de la matrice :
– la matrice est stockée en mémoire sous forme transposée (une « colonne » en entier, puis
la seconde, etc.) ;
– les dimensions des colonnes sont alignées pour que l’adresse de début de chaque co-
lonne soit un multiple de 16. Ceci est nécessaire pour que les accès mémoire séquentiels
24 2. Solveur linéaire sur GPU

sur GPU n’aboutissent qu’à une seule transaction entre la mémoire et les multiproces-
seurs ; ainsi lire (ou écrire) 16 éléments mémoire séquentiellement est aussi rapide que
d’en lire (ou écrire) un seul.
Les performances sont relativement bonnes par rapport au CPU, mais dans certains cas la
version GPU ne converge pas alors que la version CPU converge.

Pour essayer d’améliorer la précision du calcul, nous avons réimplémenté l’algorithme de


Jacobi sur GPU en utilisant une librairie émulant la double précision en utilisants deux float
au lieu d’un seul (cf. partie 2.2.3). Les résultats n’ont néanmoins pas été convaincants.

2.3.4 Algorithme du gradient biconjugué


Sur CPU, l’adaptation de l’algorithme du gradient biconjugué se fait sans aucun problème.

Sur GPU, nous avons décidé pour simplifier l’écriture du programme d’utiliser la biblio-
thèque CUBLAS pour les opérations vectorielles simples : produit scalaire (avec l’opération
BLAS SDOT) et mise à jour du type y ← y +αx (opération SAXPY). Il reste alors à écrire 3 kernels :
– calcul de b − Ax : utilisé pour calculer r 0 , et pour calculer l’erreur après quelques itéra-
tions. Dans notre code, ce kernel s’appelle delta() ;
– mise à jour de p et p̃ : kernel update_p(), sans difficulté particulière ;
– mise à jour de q et q̃ : ce kernel-ci s’est avéré beaucoup plus délicat à écrire du fait que q̃
nécessite un produit par la transposée de A 7 (cf. ligne 15 de l’Algorithme 1). Le format de
stockage choisi n’étant pas particulièrement adapté, ceci a posé de nombreux problèmes
liés à des accès mémoires inefficaces, voire incorrects (écriture simultanée depuis plu-
sieurs threads différents, donnant des valeurs aléatoires). Ces problèmes ont fini par être
résolus, et le kernel update_q(), bien qu’étant encore le goulot d’étranglement de cet
algorithme, est maintenant stable et efficace.

2.3.5 Performances
Nous avons effectué de nombreux tests de performances pour les différents solveurs im-
plémentés, tant en temps qu’en précision sur la solution trouvée.
Pour produire ces graphes, nous avons fait tourner chaque test 10 fois avec des tailles de
problèmes variables (seulement 2 fois pour les grands problèmes sur CPU pour que ce soit un
peu plus rapide) puis avons calculé la moyenne arithmétique des valeurs obtenues lors de ces
différentes exécutions. Les données mesurées sont les suivantes :
– temps d’exécution total de l’algorithme, y compris les transferts (wall time) ;
– précision, en calculant la norme infinie (maximum en valeur absolue) du vecteur d’er-
reur (kb − Axk∞ ).
Nous avons fait ces tests avec plusieurs jeux de données différentes. Nous présentons ici
deux cas différents : un premier qui est plutôt favorable, et un second qui l’est moins. Dans
tous les cas, il s’agit de problèmes construits algorithmiquement, et donc reproductibles d’une
exécution à l’autre (aucune valeur aléatoire) ; pour tracer les graphes nous avons fait varier la
taille des problèmes entre n = 100 et n = 3 000 000.

Cas favorable
Dans ce premier cas, nous avons obtenu les résultats présentés sur les figures 2.3 et 2.4.

Sur ces figures, on peut observer plusieurs phénomènes intéressants :

7. En fait, sa matrice adjointe, mais dans le cas d’une matrice réelle c’est effectivement la transposée.
2. Solveur linéaire sur GPU 25

200 000

20 000
Temps de convergence (ms)

2 000

GS CPU
Jacobi GPU
Jacobi+ GPU
200 BiCG CPU
BiCG GPU

20

2
100 1 000 10 000 100 000 1 000 000
Taille du problème

F IGURE 2.3 – Performances en temps des différents solveurs dans le cas favorable

1E+1

1E+0

1E-1

1E-2

GS CPU
Erreur

1E-3
Jacobi+ GPU
Jacobi GPU
BiCG CPU
1E-4 BiCG GPU

1E-5

1E-6

1E-7
100 1 000 10 000 100 000 1 000 000
Taille du problème

F IGURE 2.4 – Performances en précision des différents solveurs dans le cas favorable
26 2. Solveur linéaire sur GPU

– les solveurs sur CPU sont toujours plus lents, mais très précis (erreur de l’ordre de 10−6 ) ;
– les solveurs sur GPU sont plus rapides, mais largement moins précis ;
– BiCG sur GPU devient aussi précis que BiCG sur CPU pour des systèmes de taille supé-
rieure à 10 000 ;
– la précision augmente avec la taille du problème, ce qui est dû à un phénomène de com-
pensation statistique des erreurs d’arrondi ;
– la version de Jacobi utilisant l’émulation de la double précision (méthode désignée par
« Jacobi+ » sur les graphes) est trois à quatre fois plus lente que le Jacobi standard sans
rien apporter en précision ;
– le temps de convergence des solveurs sur GPU est constant pour les systèmes de taille
inférieure à 10 000, puis augmente linéairement ensuite. Ceci est dû à la latence intro-
duite par les accès mémoire : ceux-ci sont beaucoup plus lents que les calculs, et pour
les « petits » systèmes le scheduler intégré à CUDA masque cette latence par d’autres cal-
culs. Ainsi la vitesse réellement observée pour ces premières valeurs est celle des accès
mémoire. Pour des systèmes plus grands, il y a « trop » de threads : il n’y a plus aucun ins-
tant où les multiprocesseurs sont inactifs, donc augmenter la taille du système augmente
d’autant la durée d’exécution de l’algorithme ;
– pour les problèmes de très grande taille, la RAM disponible sur le GPU est un facteur
limitant, et on ne peut pas traiter d’aussi grands problèmes sur GPU que sur CPU.

Cas défavorable
Les résultats de ce second cas sont présentés sur les figures 2.5 et 2.6.

200 000

20 000
Temps de convergence (ms)

2 000

GS CPU
Jacobi GPU
Jacobi+ GPU
200 BiCG CPU
BiCG GPU

20

2
100 1 000 10 000 100 000 1 000 000
Taille du problème

F IGURE 2.5 – Performances en temps des différents solveurs dans le cas défavorable

On observe les phénomènes suivants :


– comme précédemment, le CPU est lent mais précis ;
– les solveurs sur GPU sont plus rapides, mais moins précis, et dans ce cas précis ils at-
teignent tous exactement la même précision... et elle n’est pas bonne du tout ;
– BiCG sur GPU ne converge pas toujours : dans un cas il ne converge pas et est interrompu
après 500 itérations, dans un autre il diverge et on trouve des valeurs Not A Number dans
le vecteur solution (point absent de la courbe).
2. Solveur linéaire sur GPU 27

1E+0

1E-1

1E-2

1E-3
GS CPU
Erreur

Jacobi+ GPU
1E-4 Jacobi GPU
BiCG CPU
BiCG GPU

1E-5

1E-6

1E-7
100 1 000 10 000 100 000 1 000 000
Taille du problème

F IGURE 2.6 – Performances en précision des différents solveurs dans le cas défavorable

On observe ainsi que l’algorithme du gradient biconjugué est numériquement instable :


parfois un système qui est parfaitement résolu sur CPU n’est pas résolu sur GPU en raison des
petites erreurs d’arrondi. Il faut donc être prudent avec cet algorithme.

2.4 Conclusion sur les solveurs linéaires sur GPU


D’après ce que l’on a observé ici, les solveurs sur GPU peuvent fournir un speedup compris
entre 1 et 100 par rapport à leurs homologues sur CPU, mais au pris d’une précision souvent
moindre. De plus, les performances exactes dépendent beaucoup des données : elles peuvent
être excellentes dans un cas et plus mauvaises dans l’autre, alors que sur CPU elles sont tou-
jours à peu près constantes.

Les solveurs GPU que nous avons écrits sont donc assez intéressants. En particulier, le
solveur utilisant la méthode du gradient biconjugué permet d’avoir une précision meilleure
qu’avec la méthode de Jacobi (ou au pire aussi bonne) tout en étant plus rapide dans les petits
problèmes et à peu près aussi rapide pour les systèmes plus grands. Dans certains cas, cette
méthode devient même aussi précise que son équivalent sur CPU.

Il conviendra néanmoins d’être très attentif, lors du traitement du problème réel, aux pro-
blèmes de précision induits par le GPU : dans certains cas (par exemple si le solveur sur GPU
ne converge pas), se rabattre sur un solveur CPU peut être une solution de secours acceptable.
Si l’on voulait encore améliorer les performances de ces solveurs, il faudrait probablement
utiliser des cartes plus puissantes. En particulier, il faudrait des bus mémoires plus rapides : ici,
le facteur limitant est clairement la bande passante mémoire.
Enfin, pour améliorer les performances en précision, il sera probablement nécessaire de
passer à une carte GPU plus récente, qui supporte le calcul sur des flottants en double préci-
sion ; mais ceci risque de se faire au détriment des performances brutes de calcul.
28 2. Solveur linéaire sur GPU
3. Pistes pour la suite du stage 29

Chapitre 3

Pistes pour la suite du stage

Ce stage est encore loin d’être fini. Ayant cette année suivi le Master de Recherche en In-
formatique en parallèle de ma troisième année d’École d’Ingénieurs à Supélec, ce stage doit en
effet correspondre aux critères du Master et à ceux de Supélec. À ce titre, le stage a commencé
mi-mars (à la fin des cours de Supélec), et se prolonge jusqu’à fin août 2009, soit deux mois
après la soutenance de ce stage – et presque 3 mois après la remise de ce rapport.
Dans cette partie, nous présentons ainsi succintement le travail qui reste à faire sur ce projet
au cours des prochains mois, tant au niveau du solveur linéaire sur GPU que du projet global,
à savoir la simulation du transport d’espèces chimiques.

3.1 Solveur linéaire sur GPU


Le solveur linéaire que nous avons conçu est assez efficace, mais il reste encore des moyens
de l’améliorer.

3.1.1 Améliorations algorithmiques


Nous avons vu que la précision atteinte sur GPU est très variable : excellente dans cer-
tains cas, très mauvaise dans d’autres. De plus, l’algorithme le plus performant parmi ceux
que nous avons implémentés est numériquement instable, et il diverge parfois sur GPU alors
qu’il converge sur CPU avec les mêmes données.

Pour tenter d’améliorer la précision atteinte, plusieurs pistes sont envisageables. La plus
simple est probablement d’utiliser un préconditionneur : ceci est censé permettre de « sta-
biliser » le problème, et ainsi d’obtenir à la fois une meilleure convergence et une meilleure
précision.
Dans les prochaines semaines, nous allons donc faire un état de l’art des techniques de
préconditionnement existantes, en insistant en particulier sur ce qui s’est déjà fait sur GPU.
Dans un second temps, nous implémenterons la ou les techniques les plus intéressantes et
évaluerons son impact à la fois sur la précision, la vitesse, et la qualité de la convergence.

3.1.2 Tests sur d’autres cartes


Les cartes GPU sur lesquelles nous avons travaillé jusqu’à présent sont des cartes de milieu
de gamme, normalement plutôt destinées au jeu vidéo. Parmi les cartes NVIDIA de dernière
génération, il y en a toute une gamme destinée au calcul scientifique, et qui supportent notam-
ment le calcul en double précision.
Ceci se fait néanmoins au prix de réductions du nombre d’unités de calcul disponibles : si
la précision est meilleure, le temps de calcul est largement plus important.
30 3. Pistes pour la suite du stage

Raphaël Couturier, enseignant-chercheur à l’IUT de Belfort, a accepté de nous prêter un


accès à l’une de ces cartes. Nous allons donc effectuer des tests sur ce matériel, en particulier
pour étudier quel est le gain en précision.
Ces cartes ayant moins de contraintes sur les accès mémoire, nous comptons observer
quelques améliorations en performances même en utilisant du calcul simple précision. Nous
souhaitons également évaluer ce gain afin de mieux juger de l’impact de la bande passante
mémoire sur les performances de calcul.

3.1.3 Utilisation différente du solveur


Si la précision obtenue avec le solveur reste moyenne et variable, il sera difficile de l’em-
ployer tel quel au sein du simulateur de transport d’espèces chimiques.
Une autre approche est cependant envisageable. En effet, nous avons observé que le sol-
veur est beaucoup plus rapide sur GPU que sur CPU : il met 100 fois moins de temps à conver-
ger, mais fait pourtant parfois 10 fois plus d’itérations que son homologue séquentiel. On peut
donc envisager d’utiliser le GPU pour réduire le nombre d’itérations nécessaires sur le CPU :
le GPU pourrait calculer une solution initiale correcte (en simple précision) en quelques mil-
lisecondes, et le solveur sur CPU pourrait ensuite utiliser cette solution initiale bien adaptée
pour converger beaucoup plus rapidement, et avec une précision bien meilleure (en double
précision...).
Certains ont déjà utilisé cette approche avec des résultats intéressants [TDB08], et de nom-
breuses équipes de recherche dans le monde s’intéressent actuellement aux architectures hy-
brides CPU/GPU. Nous nous proposons donc, si cela s’avère nécessaire, d’implémenter une
telle architecture pour obtenir de meilleurs résultats lors de la simulation.

3.2 Problème de transport


Le problème de transport 3D d’espèces chimiques reste le sujet principal de ce stage, bien
qu’il n’ait pas encore été abordé en détails.

Au cours des prochaines semaines, nous allons donc concevoir de manière théorique un
système « idéal », où l’on déporte le plus possible de calculs sur le GPU, et où le CPU ne sert
qu’à commander ces calculs et à effectuer les transferts réseaux.
A priori, il est certain qu’il faudra par exemple gérer le GPU depuis un seul et même thread
CPU, et les communications asynchrones (entrantes et sortantes) depuis un ou plusieurs autres
threads CPU. Pour gérer la synchronisation des données entre le CPU et le GPU, il faudra né-
cessairement utiliser des mécanismes de synchronisation classiques (exclusion mutuelle ou
sémaphores). L’architecture précise de ce système reste cependant à élaborer.

Il est particulièrement intéressant de noter que ce système comptera deux niveaux d’asyn-
chronismes différents, à deux échelles de temps totalement différentes :
– les communications entre CPU et GPU : elles seront asynchrones pour recouvrir le calcul
par des transferts, et aux débits autorisés par le bus PCI-Express reliant la carte graphique
à la carte mère, soit plusieurs gigaoctets par seconde ;
– les communications réseaux : elles seront asynchrones également, mais à des débits bien
plus faibles, le débit maximum théorique du cluster de GPU de Supélec étant d’un giga-
bits par seconde (Ethernet 1 Gbps).
La conception (et encore plus l’implémentation) d’un tel système devra donc respecter de
nombreuses contraintes temporelles pour assurer un fonctionnement correct.

Comme nous l’avons fait pour le solveur GPU, l’implémentation de ce simulateur se fera
par étapes, avec à chaque fois une validation sur CPU :
3. Pistes pour la suite du stage 31

– version fonctionnant uniquement sur CPU avant de commencer à utiliser le GPU ;


– version locale uniquement avant de commencer à travailler en réseau sur le cluster ;
– version utilisant des communications et/ou itérations synchrones (algorithme SIAC ou
SISC) avant de commencer à généraliser l’asynchronisme.

Une fois l’implémentation complète, nous évaluerons l’influence et l’intérêt de l’utilisa-


tion d’un CPU dans une telle architecture, notamment au regard des résultats obtenus dans
[BCMS06].
32 3. Pistes pour la suite du stage
Conclusion 33

Conclusion

Une partie importante du travail prévu a déjà été accomplie. Entre la mi-mars et début juin,
nous nous sommes appliqués à concevoir et implémenter un solveur linéaire sur GPU, ce qui
n’est pas une tâche simple.

En premier lieu, il a été nécessaire de choisir un format de stockage des matrices creuses.
Il fallait que celui-ci soit à la fois efficace et adapté à la programmation sur GPU. Le format de
stockage par diagonales proposé ici répond à ces contraintes, et possède également d’autres
vertus qui n’ont été découvertes que plus tard, comme la simplicité d’accès à la matrice trans-
posée par quelques manipulations d’indices simples.
Ensuite, il existe de nombreuses méthodes qui permettent de résoudre des systèmes li-
néaires. Mais relativement peu d’entre elles sont adaptées à un calcul massivement parallèle
comme celui offert par les GPU actuels. Parmi toutes les méthodes disponibles, nous avons re-
tenu deux méthodes itératives : la méthode de Jacobi (et Gauss-Seidel sur CPU), et la méthode
du gradient biconjugué.

Une fois ces méthodes choisies, il a fallu les implémenter sur GPU. Ceci a été fait, avec
des résultats très encourageant au niveau des performances. Les mêmes algorithmes ont été
implémentés sur CPU, afin de valider les résultats obtenus ; ceci a permis de confirmer que les
CPU sont largement plus lents que les GPU, mais aussi beaucoup plus précis.
En effet, les GPU, plate-formes destinées en priorité au rendu graphique pour les jeux vi-
déos, ne sont pas parfaitement adaptés au calcul intensif, en particulier au niveau de la préci-
sion des opérations en virgule flottante. Non seulement les types de données disponibles sont
d’une précision plus faibles que ceux disponibles sur CPU, mais en plus certaines opérations
essentielles, comme la division, sont implémentées matériellement d’une manière non stan-
dard qui risque de causer des erreurs d’arrondis importantes. Il convient donc de choisir des
méthodes résistantes faces à ces imprécisions, ou d’ajouter des contre-mesures visant à ré-
duire au maximum leur influence sur le résultat final. Ceci n’a été que partiellement fait, mais
d’autres améliorations sont prévues.
Le solveur linéaire résultant de ces travaux, bien qu’encore incomplet, a d’ores et déjà été
présenté à la communauté scientifique lors de la Journée jeunes chercheurs sur les Multipro-
cesseurs et Multicœurs (le 4 juin 2009) ; les réactions ont été plutôt positives, et plusieurs per-
sonnes se sont montrées très intéressées.

Au moment de l’écriture de ces lignes, le travail sur la partie concernant l’algorithmique


asynchrone est sur le point de commencer. La partie conception devrait être terminée d’ici
quelques semaines et, si tout se passe bien, le projet aura été mené à terme d’ici à la fin du
stage.
34 BIBLIOGRAPHIE

Bibliographie

[Bai05] D. Bailey. DSFUN90 : Fortran-90 double-single package. World-Wide Web site with
software archives., March 2005.
[BBC+ 94] R. Barrett, M. Berry, T. F. Chan, J. Demmel, J. Donato, J. Dongarra, V. Eijkhout,
R. Pozo, C. Romine, and H. Van der Vorst. Templates for the Solution of Linear Sys-
tems : Building Blocks for Iterative Methods, 2nd Edition. SIAM, Philadelphia, PA,
1994.
[BCLar] L. Buatois, G. Caumon, and B. Lévy. Concurrent number cruncher - a GPU im-
plementation of a general sparse linear solver. International Journal of Parallel,
Emergent and Distributed Systems, to appear.
[BCMS06] J. Bahi, R. Couturier, K. Mazouzi, and M. Salomon. Synchronous and asynchronous
solution of a 3D transport model in a grid computing environment. Applied Mathe-
matical Modelling, 30(7) :616–628, 2006.
[BCVC07] J. Bahi, S. Contassot-Vivier, and R. Couturier. Parallel Iterative Algorithms : from
sequential to grid computing, volume 1 of Numerical Analysis & Scientific Compu-
tating. Chapman & Hall/CRC, 2007.
[BG08] Nathan Bell and Michael Garland. Efficient sparse matrix-vector multiplication on
CUDA. NVIDIA Technical Report NVR-2008-004, NVIDIA Corporation, December
2008.
[Des06] P. Dessante. Analyse numérique et optimisation. Supélec, 2006.
[DMP+ 08] J. Dongarra, S. Moore, G. Peterson, S. Tomov, J. Allred, V. Natoli, and D. Richie. Explo-
ring new architectures in accelerating CFD for air force applications. In Proceedings
of the DoD HPCMP User Group Conference, July 2008.
[NVI08] NVIDIA. NVIDIA CUDA 2.1 Programming Guide, December 2008.
[QO08] E. Quintana-Ortí. Use of GPUs in dense linear algebra. Seminar at National Instru-
ments, Austin, April 2008.
[TDB08] Stanimire Tomov, Jack Dongarra, , and Marc Baboulin. Towards dense linear alge-
bra for hybrid gpu accelerated manycore systems. Technical Report 210, LAPACK
Working Note, October 2008.
[VD08] V. Volkov and J. Demmel. Benchmarking GPUs to tune dense linear algebra. In SC,
page 31, 2008.
[Wik09] Wikipedia. QR decomposition, 2009. [Online ; accessed 23-March-2009].

Vous aimerez peut-être aussi