Expérience de programmation avec HMPP

De Irma-wiki.


Navaro 5 avril 2011 à 10:44 (CEST)

Sommaire

HMPP (Hybrid Multicore Parallel Programming)

HMPP permet la programmation par incrémentation: on ajoute des directives progressivement pour améliorer les performances tout en conservant le fonctionnement sur CPU.

Première étape: Définir quelle partie du code sera exécutée sur le GPU. Il faut isoler dans le programme les routines destinées au GPU nommées "codelette". La codelette doit posséder les propriétés suivantes:

  • Ne contient pas de variables qui ne soient pas liées directement à l'un des arguments. Dans le cas contraire celles-ci doivent être signalées par une directive HMPP.
  • Ne contient aucun appel de fonction autre que des fonctions mathématiques.
  • Ne contient aucune directive HMPP.

Deuxième étape: Optimiser les mouvements de données entre le CPU et le GPU, sachant que:

  • Les résultats d'opérations arithmétiques sur GPU peuvent être différents des résultats sur CPU
  • Les transferts CPU-GPU peuvent être extrêmement pénalisants.

Il faut donc prendre soin de:

  • Définir le caractère entrée et/ou sortie des arguments de la codelette.
  • Prévoir les allocations mémoire nécessaires sur le GPU.
  • Exécuter si possible la codelette de manière asynchrone pour que le code sur CPU continue de travailler.
  • Grouper au maximum tout ce qui sera exécuté sur le GPU.
  • S'assurer que les données partagées entre codelettes soient localisées dans la mémoire du CPU.

Gérer le transfert des données sur le GPU

Considérons un ensemble de deux codelettes partageant des données, il faut définir ce groupe avec la directive:

!$hmpp <MonGroupe> group, target=CUDA

Avec ces directive on spécifie également le langage du code généré par HMPP. Deux solutions:

  • CUDA pour les cartes NVIDIA
  • OpenCL pour les cartes NVIDIA et ATI

Il faut délimiter les codelettes en plaçant la directive "codelet" devant la définition de chaque routine:

!$hmpp <MonGroupe> label1 codelet
subroutine routine1(N, S, T)
integer, intent(in) :: N
real, dimension(N), intent(in) :: S
real, dimension(N), intent(out):: T
!$hmpp <MonGroupe> label2 codelet
subroutine routine2(N, T, F)
integer, intent(in) :: N
real, dimension(N), intent(in) :: T
real, dimension(N), intent(out):: F

Les labels sont uniques

Ensuite il faut placer une directive devant chaque appel des codelettes:

!$hmpp <MonGroupe> codelette1 callsite
call routine1(N,S,T)
!$hmpp <MonGroupe> codelette2 callsite
call routine2(N,T,U)

Par défaut HMPP alloue la mémoire sur le GPU avant chaque "callsite". Cette operation est extrêmement pénalisante il faut donc contrôler l'allocation et la libération de la mémoire sur le GPU. On utilise les directives suivantes:

!$hmpp <MonGroupe> allocate
!$hmpp <MonGroupe> codelette1 callsite
call routine1(N,T)
!$hmpp <MonGroupe> codelette2 callsite
call routine2(N,T,U)
!hmpp <MonGroupe> release

Par défaut HMPP transfère les valeurs de tous les arguments de la codelette sur le GPU après chaque directive "callsite" Pour déclencher le chargement des valeurs d'un tableau T sur le GPU on utilise:

!$hmpp <MonGroupe> mapbyname, T
!$hmpp <MonGroupe> codelette1 advancedload, args[T]

Il faut que le tableau T soit l'un des arguments de routine1. La directive "mapbyname" signifie au GPU que le tableau T occupe le meme espace memoire pour la routine 1 et la routine 2. Si entre routine1 et routine2 leur argument commun, le tableau T est inchangé alors il est judicieux de le laisser sur le GPU. Dans ce cas avant l'appel des deux routines, ajoutez:

!$hmpp <MonGroupe> codelette1 callsite, args[T].noupdate=true
call routine1(N,S,T)
!$hmpp <MonGroupe> codelette2 callsite, args[T].noupdate=true
call routine2(N,T,U)

Dans le cas ou l'on souhaite récupérer T pour la suite du programme alors

!$hmpp <MonGroupe> codelette1 delegatestore, args[T]

si T a été modifié pour la dernière fois dans routine1. Remarque: Si la directive delegatestore n'est pas présente dans le code vous aurez un avertissement a la compilation.

Si la codelette est appelée dans une boucle alors il faut définir les données qui devront rester sur le GPU durant chaque itération. Exemple: un tableau S de taille N constante durant les itérations qu'on utilise pour modifier le tableau T. Les valeurs de S restent sur le GPU. On ajoute alors avant la description de la routine1:

!$hmpp <MonGroupe> codelette1, args[N,S].const=true
subroutine routine1(N,S,T)
...
end subroutine routine1

Les valeurs de S seront initialisées sur le GPU lors de la première itération. On économisera tous les transferts de S lors de tous les appels de routine1.

Dans le cas d'une variable globale commune aux deux unités de calcul, il faut absolument transférer celle-ci sur le GPU. On utilise pour cela le concept de données "résidentes". On utilise ce concept pour les données nécessaires aux calculs effectués sur le GPU mais qui ne font pas partie des arguments des codelettes. On ajoute une directive juste avant la déclaration de la variable:

!$hmpp <MonGroupe> resident
!real : varglob

Note importante: L'utilisation des COMMON en fortran n'est pas supportée par HMPP. L'utilisation de modules "externes" en fortran 90 (variable importée via un "use") nécessite un transfert explicite vers le GPU des données "residentes" avec une allocation:

!$hmpp <MonGroupe> codelette1 advancedload, args[::varglob]

les "::" signifie que cette variable n'est pas liée a une codelette.

Que faire pour optimiser l'accélération?

HMPP exporte votre code Fortran en code CUDA ou OpenCL avec l'aide des directives. Les boucles sont parallélisées et exécutées via un groupe de "threads". Pour optimiser cet export, il existe quelques règles de base:

  • Posséder un maximum d'itérations parallèles pour "charger" le GPU.
  • Optimiser les accès mémoire en respectant la continuité des données (colonnes en fortran, lignes en C).
  • Calibrer la taille de threads en fonction de votre carte graphique
  • Exploiter au maximum la mémoire partagée en deux threads.

Pour executer une codelette de maniere non bloquante on utilise le mot cle asynchronous dans la directive callsite.

!$hmpp <MyGroup> label1 callsite, asynchronous

Pour s'assurer que les calculs sont termines sur le GPU on utilisera la directive:

!$hmpp <MyGroup> synchronize

Exemple en Fortran

Pour illustrer l'utilisation d'HMPP, utilisons ce programme qui calcule l'évolution d'une distribution de particules tourbillonnaires dont la vitesse induite est calculée par la loi de Biot et Savart. On utilise une analogie avec la magnétostatique ou l'on admet que la vorticité correspond au courant, et la vitesse induite à l'intensité du champ magnétique. On résout une intégrale avec une double boucle sur l'ensemble des particules puisque chaque particule contribuera a la vitesse induite de toutes les autres.

  • (xp,vp) = positions des particules
  • (up,vp) = vitesses des particules
  • op = circulation des particules
  • (ut,vt) = vitesse globale de l'écoulement
  • nbpart = nombre de particules
  • dt = pas de temps
  • nstep = nombre de pas de temps
  • delta = paramètre de régularisation de l'intégrale de Biot-Savart.

Pour obtenir le code

svn co --username guest http://www-irma.u-strasbg.fr/subversion/Biot_Savart

program biot
!$hmpp <myGroup> group, target=OPENCL
!les donnees qui resteront sur le GPU
!$hmpp <myGroup> mapbyname, up
!$hmpp <myGroup> mapbyname, vp


real :: delta, dt, ut, vt
...
!Chargement des données sur le GPU
!$hmpp <myGroup> allocate
!$hmpp <myGroup> deplace advancedload, args[up,vp]
...
do istep = 1, nstep    
  ...
  !$hmpp <myGroup> deplace callsite, args[up,vp].noupdate=true &
  !$hmpp & , asynchronous 
  call vitesse(nbpart, xp, yp, op, up, vp, delta)  
  ...
  ! quelques calculs sur le CPU ^pendant que le GPU travaille
  ...
  !$hmpp <myGroup> synchronize 
  !$hmpp <myGroup> deplace callsite, args[up,vp].noupdate=true
  call deplace(nbpart, xp, yp, up, vp, ut, vt, dt)
  ...
  time = time + dt
end do   
...
!récupérer les valeurs de up, vp
!$hmpp <myGroup> deplace delegatestore, args[up,vp]
!$hmpp <myGroup> release

contains

!$hmpp <myGroup> vitesse callsite
!$hmpp <myGroup> vitesse codelet, args[nbpart,op,delta].const=true
subroutine vitesse(nbpart, xp, yp, op, up, vp, delta)
real, dimension(nbpart), intent(in) :: xp, yp, op
real, dimension(nbpart), intent(inout) :: up, vp
real, intent(in) :: delta
do k = 1, nbpart
  do j = 1 , nbpart
    up(k) = ...
    vp(k) = ...
  end do
end do
end subroutine vitesse
...
!$hmpp <myGroup> deplace callsite
!$hmpp <myGroup> deplace codelet, args[nbpart,dt].const=true
subroutine deplace(nbpart, xp, yp, up, vp, ut, vt, dt)
real, dimension(nbpart), intent(inout) :: xp, yp
real, dimension(nbpart), intent(in) :: up, vp
real, intent(in) :: ut, vt, dt
!$hmpp unroll, k:4
do k = 1, nbpart
  xp(k)  = xp(k) + dt * (up(k)+ut)
  yp(k)  = yp(k) + dt * (vp(k)+vt)
end do
end programme biot

Matériel et compilateurs

Les tests sont effectues sur irma-gpu1 composé de:

  • Carte graphique NVIDIA GTX 470 1280 Mo
  • Processeur Intel Pentium Dual Core 2.2 Ghz 2Gb RAM
  • HMPP 2.3.2
  • gfortran version 4.4.1 (Ubuntu 9.10)
  • CUDA toolkit 3.1

Le Makefile

ATI_DISPLAY    = DISPLAY=:0.0
HMPP           = hmpp

FC = $(HMPP) gfortran
OPTS = -O3
FFLAGS = $(OPTS) -I/usr/local/include

LD = $(FC)
LDFLAGS = -lz  -L/usr/local/lib -lhdf5 -lsiloh5

PROG =  biot.exe
SRCS =  initial.f90 main.f90 sorties.f90
OBJS =  initial.o main.o sorties.o

all : $(PROG)

$(PROG): $(OBJS)
        $(FC) $(FFLAGS) $(OBJS) -o $@ $(LDFLAGS)

run: $(PROG)
        $(ATI_DISPLAY) ./$(PROG)

.SUFFIXES: $(SUFFIXES) .o .f90 .mod .f

.f.o:
        $(FC) $(FFLAGS) -c $<
.f90.o:
        $(FC) $(FFLAGS) -c $<
.mod.o:
        $(FC) $(FFLAGS) -c $*.f90

main.o: main.f90 initial.o sorties.o
initial.o: initial.f90
sorties.o: sorties.f90

Temps de calcul

Les temps de calcul sont évalués avec la routine F90 CPUTIME appelée en début et fin de programme.

  • Le code compilé avec gfortran s'exécute avec un temps de 1120 secondes
  • Le code compilé avec le préprocesseur HMPP se termine au bout de 49 secondes

L'accélération obtenue est égale à 22. Précisons quand même que la génération de la carte est plus récente que celle du CPU.

Conclusion

  • L'installation est rapide et facile
  • HMPP possède les même avantages qu'OPENMP, on peut éxecuter le meme code sur CPU ou GPU sans modifier les sources, seul le Makefile change.
  • Dans notre exemple nous avons pris soin de conserver des sorties fichiers pour la visualisation. Avec quelques lignes nous avons réussi à multiplier par 20 la vitesse d'exécution.
  • Le Makefile reste très simple
  • Il faut transformer le code pour faciliter le placement des directives (mise en évidence des "codelettes") mais cela n'altère pas le fonctionnement sur CPU.
  • La comparaison du même code avec et sans HMPP n'est pas très honnête. Il faudrait comparer avec un code utilisant des librairies optimisées sur CPU.

Bibliographie

Outils personnels