OpenACC Tutorial - Adding directives/fr: Difference between revisions

(Updating to match new version of source page)
(Updating to match new version of source page)
 
(133 intermediate revisions by 4 users not shown)
Line 4: Line 4:
|title=Objectifs d'apprentissage
|title=Objectifs d'apprentissage
|content=
|content=
* Understand the process of ''offloading''
* comprendre le processus de transfert (''offloading'')
* Understand what is an OpenACC directive.
* comprendre ce qu'est une directive OpenACC  
* Understand what is the difference between the <tt>loop</tt> and <tt>kernels</tt> directive.
* connaitre la différence entre les directives <tt>loop</tt> et <tt>kernels</tt>
* Understand how to build a program with OpenACC
* savoir programmer avec OpenACC
* Understand what ''aliasing'' is in C/C++
* comprendre le concept d'alias en C/C++
* Learn how to use compiler feedback and how fix false aliasing.
* savoir utiliser la rétroaction du compilateur et éviter les faux alias
}}
}}


== Offloading to a GPU ==
== Transfert vers un processeur graphique (GPU) ==  
The first thing to realize when trying to port a code to a GPU is that they do not share the same memory as the CPU. In other words, a GPU does not have direct access to the host memory. The host memory is generally larger, but slower than the GPU memory. To use a GPU, data must therefore be transferred from the main program to the GPU through the PCI bus, which has a much lower bandwidth than either memories. This means that managing data transfer between the host and the GPU will be of paramount importance. Transferring the data and the code onto the device is called ''offloading''.
Avant de porter du code sur un GPU, il faut savoir que ceux-ci ne partagent pas la même mémoire que le CPU de l'hôte.
* la mémoire de l'hôte est en général plus grande, mais plus lente que la mémoire du GPU;
* un GPU n'a pas d'accès direct à la mémoire de l'hôte;
* pour pouvoir utiliser un GPU, les données doivent passer par le bus PCI, dont la bande passante est moins grande que celles du CPU et du GPU;
* il est donc de la plus haute importance de bien gérer les transferts entre la mémoire de départ et le GPU. En anglais, ce processus s'appelle ''offloading''.


== OpenACC directives ==
==Directives OpenACC== <!--T:4-->
OpenACC directives are much like OpenMP directives. They take the form of <tt>pragma</tt> in C/C++, and comments in Fortran. There are several advantages to using directives. First, since it involves very minor modifications to the code, changes can be done ''incrementally'', one <tt>pragma</tt> at a time. This is especially useful for debugging purpose, since making a single change at a time allows one to quickly identify which change created a bug. Second, OpenACC support can be disabled at compile time. When OpenACC support is disabled, the <tt>pragma</tt> are considered comments, and ignored by the compiler. This means that a single source code can be used to compile both an accelerated version and a normal version. Third, since all of the offloading work is done by the compiler, the same code can be compiled for various accelerator types: GPUs, MIC (Xeon Phi) or CPUs. It also means that a new generation of devices only requires one to update the compiler, not to change the code.  
Les directives OpenAcc sont semblables aux directives [[OpenMP/fr|OpenMP]]. En C/C++, ce sont des énoncés <tt>pragmas</tt> et en Fortran, des commentaires. L'emploi de directives comporte plusieurs avantages&nbsp;:
* Premièrement, puisque le code est peu affecté, les modifications peuvent se faire de manière incrémentale, un <tt>pragma</tt> à la fois;  ceci est particulièrement utile pour le débogage puisqu'il est ainsi facile d'identifier le changement précis qui crée le bogue.
* Deuxièmement, OpenACC peut être désactivé au moment de la compilation;  les <tt>pragmas</tt> sont alors vus comme étant des commentaires et ne sont pas considérés par le compilateur, ce qui permet de compiler une version accélérée et une version normale à partir du même code source.
* Troisièmement, comme le compilateur fait tout le travail de transfert, le même code peut être compilé pour différents types d'accélérateurs, que ce soit un GPU ou des instructions SIMD sur un CPU;    ainsi, un changement du matériel exigera simplement la mise à jour du compilateur, sans modification au code.  


In the following example, we take a code comprised of two loops. The first one initializes two vectors, and the second performs a <tt>[https://en.wikipedia.org/wiki/Basic_Linear_Algebra_Subprograms#Level_1 SAXPY]</tt>, a basic vector addition operation.  
Le code de notre exemple contient deux boucles&nbsp;: la première initialise deux vecteurs et la seconde effectue une opération de [https://fr.wikipedia.org/wiki/Basic_Linear_Algebra_Subprograms niveau 1] d'addition des vecteurs.  


{| class="wikitable" width="100%"
{| class="wikitable" width="100%"
Line 49: Line 56:
</syntaxhighlight>
</syntaxhighlight>
|}
|}
Both in the C/C++ and the Fortran cases, the compiler will identify '''two''' kernels. In C/C++, the two kernels will correspond to the inside of each loops. In Fortran, the kernels will be the inside of the first loop, as well as the inside of the implicit loop that Fortran performs when it does an array operation.
Dans les deux cas, le compilateur identifie deux noyaux (''kernels'')&nbsp;:
* en C/C++, les deux noyaux sont à l'intérieur de chaque boucle.;
* en Fortran, les noyaux sont à l'intérieur de la première boucle et à l'intérieur de la boucle implicite effectuée lors d'une opération sur des tableaux.


Note that in C/C++, the OpenACC block is delimited using curly brackets, while in Fortran, the same comment needs to be repeated, with the <tt>end</tt> keyword added.
Remarquez que le bloc OpenACC est délimité en C/C++ par des accolades; en Fortran, le commentaire est placé une fois au début et une dernière fois à la fin, avec l'ajout cette fois de <tt>end</tt>.
 
=== Boucles et noyaux ===
 
Quand le compilateur lit la directive OpenACC <tt>kernels</tt>, il analyse le code pour identifier les sections pouvant être parallélisées.
Ceci correspond souvent au corps d'une boucle qui a des itérations indépendantes.
Dans ce cas, le compilateur délimite le début et la fin du corps du code avec la fonction [https://en.wikipedia.org/wiki/Compute_kernel ''kernel''].
Les appels à cette fonction ne seront pas affectés par les autres appels.
La fonction est compilée et peut ensuite être exécutée sur un accélérateur.
Comme chaque appel est indépendant, chacun des centaines de cœurs de l'accélérateur peut exécuter la fonction en parallèle pour un index spécifique.


=== Loops vs Kernels ===
When the compiler reaches an OpenACC <tt>kernels</tt> directive, it will analyze the code in order to identify sections that can be parallelized. This often corresponds to the body of the loop. When such a case is identified, the compiler will wrap the body of the code into a special function called a ''kernel''. This function makes it clear that each call to the function is independent from any other call. The function is then compiled to enable it to run on an accelerator. Since each call is independent, each one of the thousands cores of the accelerator can run the function for one specific index in parallel.
{| class="wikitable" width="100%"
{| class="wikitable" width="100%"
|-
|-
! LOOP !! KERNEL
! BOUCLE !! KERNEL
|-
|-
| <syntaxhighlight lang="cpp" line>
| <syntaxhighlight lang="cpp" line>
Line 65: Line 81:
}
}
</syntaxhighlight> || <syntaxhighlight lang="cpp" line>
</syntaxhighlight> || <syntaxhighlight lang="cpp" line>
void loopBody(A,B,C,i)
void kernelName(A, B, C, i)
{
{
   C[i] = A[i] + B[i];
   C[i] = A[i] + B[i];
Line 71: Line 87:
</syntaxhighlight>
</syntaxhighlight>
|-
|-
|Calculate 0 - N in order || Each compute core calculates one value of <tt>i</tt>.
| Calcule séquentiellement de <tt>i=0</tt> à <tt>i=N-1</tt>, inclusivement. || Chaque unité de calcul exécute la fonction pour une seule valeur de <tt>i</tt>.
|}
|}


== The <tt>kernels</tt> directive ==
== La directive <code>kernels</code> ==
The <tt>kernels</tt> directive is what we call a ''descriptive'' directive. It is used to tell the compiler that the programmer thinks this region can be made parallel. At this point, the compiler is free to do whatever it wants with this information. It can use whichever strategy it thinks is best to run the code, ''including'' running it sequentially. Typically, it will
Cette directive est dite ''descriptive''. Le programmeur l'utilise pour signifier au compilateur les portions qui selon lui peuvent être parallélisées. Le compilateur fait ce qu'il veut de cette information et adopte la stratégie qui lui semble la meilleure pour exécuter le code, '''incluant''' son exécution séquentielle. De façon générale, le compilateur
# Analyze the code to try to identify parallelism
# analyse le code pour détecter le parallélisme,
# If found, identify which data must be transferred and when
# s'il détecte du parallélisme, identifie les données à transférer et décide quand faire le transfert,
# Create a kernel
# crée un kernel,
# Offload the kernel to the GPU
# transfère le kernel au GPU.


One example of this directive is the following code:
Voici un exemple de cette directive&nbsp;:


<syntaxhighlight lang="cpp" line highlight="1,2,7">
<syntaxhighlight lang="cpp" line highlight="1,2,7">
#pragma acc kernels
#pragma acc kernels
{
{
for (int i=0; i<N; i++)
  for (int i=0; i<N; i++)
{
  {
  C[i] = A[i] + B[i];
    C[i] = A[i] + B[i];
}
  }
}
}
</syntaxhighlight>  
</syntaxhighlight>  


This example is very simple. However, code is often not that simple, and we then need to rely on compiler feedback in order to identify regions it failed to parallelize.  
Il est rare que le code soit aussi simple et il faut se baser sur la [[OpenACC_Tutorial_-_Profiling/fr#Renseignements_sur_le_compilateur| rétroaction du compilateur]] pour trouver les portions qu'il a négligé de paralléliser.  
 
{{Callout
{{Callout
|title=Descriptive vs prescriptive
|title=Description ou prescription
|content=
|content=
Those who have used [[OpenMP]] before will be familiar with the directive based nature of OpenACC. There is however one major difference between OpenMP and OpenACC directives. OpenMP directives are by design ''prescriptive'' in nature. This means that the compiler is required to perform the requested parallelization, no matter whether this is good from a performance stand point or not. This yields very reproducible results from one compiler to the next. This also means that parallelization will be performed the same way, whatever the hardware the code runs on. However, not every architecture performs best with code written the same way. Sometimes, it may be beneficial to switch the order of loops for example. If one were to parallelize a code with OpenMP and wanted it to perform optimally on multiple different architectures, they would have to write different sets of directives for different architectures.  
Si vous avez déjà utilisé [[OpenMP/fr|OpenMP]], vous retrouverez dans OpenACC le principe de ''directives''. Il existe cependant d'importantes différences entre les directives OpenMP et OpenACC&nbsp;:
* Les directives OpenMP sont à la base ''prescriptives''. Ceci signifie que le compilateur est forcé d'accomplir la parallélisation, peu importe que l'effet détériore ou améliore la performance. Le résultat est prévisible pour tous les compilateurs. De plus, la parallélisation se fera de la même manière, peu importe le matériel utilisé pour exécuter le code. Par contre, le même code peut connaitre une moins bonne performance, dépendant de l'architecture. Il peut donc être préférable par exemple de changer l'ordre des boucles. Pour paralléliser du code avec OpenMP et obtenir une performance optimale dans différentes architectures, il faudrait avoir un ensemble différent de directives pour chaque architecture.  


In contrast, many of OpenACC's directives are ''descriptive'' in nature. This means that the compiler is free to compile the code whichever way it thinks is best for the target architecture. This may even imply that the code is not parallelized at all. The '''same code''', compiled to run on GPU, or on Xeon Phi, or on CPU, may therefore yield different binary code. This, of course, means that different compilers may yield different performance. It also means that new generations of compilers will do better than previous generations, especially with new hardware.
* Pour leur part, plusieurs directives OpenACC sont de nature ''descriptive''. Ici, le compilateur est libre de compiler le code de la façon qu'il juge la meilleure, selon l'architecture visée. Dans certains cas, le code ne sera pas parallélisé du tout. Le '''même code''' exécuté sur un GPU ou sur un CPU peut donner du code binaire différent. Ceci signifie que la performance pourrait varier selon le compilateur  et que les compilateurs d'une nouvelle génération seront plus efficaces, surtout en présence de nouveau matériel.
}}
}}


=== Example: porting a matrix-vector product ===
=== Exemple : porter un produit matrice-vecteur ===  
For this example, we use the code from the [https://github.com/calculquebec/cq-formation-openacc exercises repository]. More precisely, we will use a portion of the code from the <tt>matrix_functions.h</tt> file. The equivalent Fortran code can be found in the subroutine <tt>matvec</tt> contained in the <tt>matrix.F90</tt> file. The original code is the following:
Pour notre exemple, nous utilisons du code provenant du  [https://github.com/calculquebec/cq-formation-openacc répertoire Github], particulièrement une portion de code [https://github.com/calculquebec/cq-formation-openacc/blob/main/cpp/matrix_functions.h#L20 fichier <code>cpp/matrix_functions.h</code>]. Le code Fortran équivalent se trouve dans la sous-routine [https://github.com/calculquebec/cq-formation-openacc/blob/main/f90/matrix.F90#L101 <code>matvec</code> contenue dans le fichier <code>matrix.F90</code>]. Le code C++ est comme suit&nbsp;:
<syntaxhighlight lang="cpp" line>
<syntaxhighlight lang="cpp" line start="29">
for(int i=0;i<num_rows;i++) {
  for(int i=0;i<num_rows;i++) {
  double sum=0;
    double sum=0;
  int row_start=row_offsets[i];
    int row_start=row_offsets[i];
  int row_end=row_offsets[i+1];
    int row_end=row_offsets[i+1];
  for(int j=row_start;j<row_end;j++) {
    for(int j=row_start;j<row_end;j++) {
    unsigned int Acol=cols[j];
      unsigned int Acol=cols[j];
    double Acoef=Acoefs[j];
      double Acoef=Acoefs[j];
    double xcoef=xcoefs[Acol];
      double xcoef=xcoefs[Acol];
    sum+=Acoef*xcoef;
      sum+=Acoef*xcoef;
    }
    ycoefs[i]=sum;
   }
   }
  ycoefs[i]=sum;
}
</syntaxhighlight>  
</syntaxhighlight>  


The first change we make to this code to try to run it on the GPU is to add the <tt>kernels</tt> directive. At this stage, we don't worry about data transfer, or about giving more information to the compiler.
Le  [https://github.com/calculquebec/cq-formation-openacc/blob/main/cpp/step1.kernels/matrix_functions.h#L29  premier changement] à faire au code est d'ajouter la directive <tt>kernels</tt> pour essayer de le faire exécuter sur le GPU.
<syntaxhighlight lang="cpp" line highlight="1,2,15">
Pour l'instant, nous n'avons pas à nous préoccuper du transfert des données ou à fournir des renseignements au compilateur.
 
<syntaxhighlight lang="cpp" line start="29" highlight="1,2,15">
#pragma acc kernels
#pragma acc kernels
   {
   {
Line 138: Line 158:
</syntaxhighlight>  
</syntaxhighlight>  


==== Building with OpenACC ====
==== Construire avec OpenACC ====
For the purpose of this tutorial, we use version 16.3 of the PGI compilers. We use the <tt>-ta</tt> (target accelerator) option in order to enable offloading to accelerators. With this option, we use the sub option <tt>tesla:managed</tt>, to tell the compiler that we want it compiled for Tesla GPUs, and we want to use managed memory. Managed memory simplifies the process of transferring data to and from the device. We will remove this option in a later example. We also use the option <tt>-fast</tt>, which is an optimization option.
 
Les compilateurs NVidia utilisent l'option <code>-acc</code> pour permettre la compilation pour un accélérateur. Nous utilisons la sous-option <code>-gpu=managed</code> pour indiquer au compilateur que nous voulons utiliser la [https://developer.nvidia.com/blog/unified-memory-cuda-beginners/ mémoire gérée]  pour simplifier le transfert de données en provenance et à destination du périphérique; nous n'utiliserons pas cette option dans un prochain exemple. Nous utilisons aussi l'option <code>-fast</code> pour l'optimisation.


{{Command
{{Command
|pgc++ -fast -Minfo{{=}}accel -ta{{=}}tesla:managed main.cpp -o challenge
|nvc++ -fast -Minfo{{=}}accel -acc -gpu{{=}}managed main.cpp -o challenge
|result=
|result=
...
...
matvec(const matrix &, const vector &, const vector &):
matvec(const matrix &, const vector &, const vector &):
     23, include "matrix_functions.h"
     23, include "matrix_functions.h"
           27, Generating copyout(ycoefs[:num_rows])
           30, Generating implicit copyin(cols[:],row_offsets[:num_rows+1],Acoefs[:]) [if not already present]
               Generating copyin(xcoefs[:],Acoefs[:],cols[:],row_offsets[:num_rows+1])
               Generating implicit copyout(ycoefs[:num_rows]) [if not already present]
           29, Complex loop carried dependence of row_offsets-> prevents parallelization
              Generating implicit copyin(xcoefs[:]) [if not already present]
              Loop carried dependence of ycoefs-> prevents parallelization
           31, Loop carried dependence of ycoefs-> prevents parallelization
               Loop carried backward dependence of ycoefs-> prevents vectorization
               Loop carried backward dependence of ycoefs-> prevents vectorization
               Complex loop carried dependence of cols->,Acoefs->,xcoefs-> prevents parallelization
               Complex loop carried dependence of Acoefs->,xcoefs-> prevents parallelization
               Accelerator kernel generated
               Generating NVIDIA GPU code
               Generating Tesla code
               31, #pragma acc loop seq
               33, #pragma acc loop vector(128) /* threadIdx.x */
               35, #pragma acc loop vector(128) /* threadIdx.x */
              37, Sum reduction generated for sum
                  Generating implicit reduction(+:sum)
           33, Loop is parallelizable
           35, Loop is parallelizable
}}
As we can see in the compiler output, the compiler could not parallelize the two loops. We will see in the following sections how to deal with this.
{{Callout
|title=Which compiler ?
|content=
As of May 2016, compiler support for OpenACC is still relatively scarce. Being pushed by [http://www.nvidia.com/content/global/global.php NVidia], through its [http://www.pgroup.com/ Portland Group] division, as well as by [http://www.cray.com/ Cray], these two lines of compilers offer the most advanced OpenACC support. [https://gcc.gnu.org/wiki/OpenACC GNU Compiler] support for OpenACC exists, but is considered experimental in version 5. It is expected to be officially supported in version 6 of the compiler.
 
For the purpose of this tutorial, we use version 16.3 of the Portland Group compilers. We note that [http://www.pgroup.com/support/download_pgi2016.php?view=current Portland Group compilers] are free for academic usage.
}}
}}


Le résultat montre que la boucle externe sur la ligne 31 n'a pas pu être parallélisée par le compilateur. Dans la prochaine section, nous expliquons comment traiter ces dépendances.


== Fixing false loop dependencies ==
== Réparer les fausses dépendances de boucles ==
Sometimes, the compiler believes that loops cannot be parallelized despite being obvious to the programmer. One common case, in C and C++, is what is called ''[https://en.wikipedia.org/wiki/Pointer_aliasing pointer aliasing]''. Contrary to Fortran arrays, C and C++ do not formally have arrays. They have what is called pointers. Two pointers are said to be ''aliased'' if they point to the same memory. If the compiler does not know that pointers are not aliased, it must assume that they are. Going back to the previous example, it becomes obvious why the compiler could not parallelize the loop. If we assume that each pointer is the same, then there is an obvious dependence between loop iterations.  
Même lorsque le programmeur sait qu'une boucle peut être parallélisée, il arrive que le compilateur ne le remarque pas. Un cas commun en C/C++ est connu sous le nom de [https://en.wikipedia.org/wiki/Pointer_aliasing ''pointer aliasing'']. Contrairement au Fortran, C/C++ ne possèdent pas comme tel de tableaux (''arrays''), mais plutôt des pointeurs. Le concept d'alias s'applique à deux pointeurs dirigés vers la même mémoire. Si le compilateur ne sait pas que des pointeurs ne sont pas des alias, il doit cependant le supposer. Dans l'exemple précédent, on voit clairement pourquoi le compilateur ne pouvait pas paralléliser la boucle. En supposant que les pointeurs sont identiques, il y a forcément dépendance des itérations de la boucle.  


=== <tt>restrict</tt> keyword ===
===Mot-clé <tt>restrict</tt> ===  
One way to tell the compiler that pointers are '''not''' going to be aliased, is by using a special keyword. In C, the keyword <tt>restrict</tt> was introduced in C99 for this purposeIn C++, there is no standard way yet, but each compiler typically has its own keyword. Either <tt>__restrict</tt> or <tt>__restrict__</tt> can be used depending on the compiler. For Portland Group compilers, the keyword is <tt>__restrict</tt>. For an explanation as to why there is no standard way to do this in C++, you can read [http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2014/n3988.pdf this paper]. This concept is important not only for OpenACC, but for any C/C++ programming, since many more optimizations can be done by compilers when pointers are guaranteed not to be aliased. Note that the keyword goes ''after'' the pointer, since it refers to the pointer, and not to the type. In other words, you would declare <code>float * __restrict A;</code> rather than <code>float __restrict * A;</code>.  
Une des manières de dire au compilateur que les pointeurs '''ne sont pas''' des alias est d'utiliser  le mot-clé  <tt>restrict</tt>, introduit à cette fin dans C99.  Il n'y a toujours pas de manière standard pour ce faire en C++, mais chaque compilateur possède un mot-clé qui lui est propre. Dépendant du compilateur, on peut utiliser <tt>__restrict</tt> ou <tt>__restrict__</tt>. Les compilateurs du Portland Group et de NVidia utilisent <tt>__restrict</tt>. Pour savoir pourquoi il n'existe pas de standard en C++, consultez [http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2014/n3988.pdf ce document]. Ce concept est important pour OpenACC comme pour toute programmation C/C++, car les compilateurs peuvent effectuer plusieurs autres optimisations si les pointeurs ne sont pas des alias. Remarquez que le mot-clé se place '''après''' le pointeur puisque c'est à ce dernier qu'il se réfère, et non au type; autrement dit, la déclaration doit se lire <code>float * __restrict A;</code> plutôt que <code>float __restrict * A;</code>.  




{{Callout
{{Callout
|title=What does <tt>restrict</tt> really mean ?
|title=Utilisation du mot-clé <tt>restrict</tt>
|content=
|content=
Declaring a pointer as restricted formally means that for "the lifetime of the pointer, only it or a value derived from it (such as <tt>ptr +1</tt>) will be used to access the object to which it points". This is a guarantee that the ''programmer'' gives to the ''compiler''. If the programmer violates this guarantee, behaviour is undefined. For more information on this concept, see this [https://en.wikipedia.org/wiki/Restrict Wikipedia article].  
En déclarant un pointeur comme étant ''restreint'', on s'assure qu'uniquement ce pointeur ou une valeur dérivée (comme <tt>ptr +1</tt>) pourra accéder à l'objet auquel il réfère, et ce pour la durée de vie du pointeur. Ceci est une garantie que le programmeur donne au compilateur;  si le programmeur manque à son obligation, le comportement n'est pas défini. Pour plus d'information, consultez l'article Wikipédia [https://en.wikipedia.org/wiki/Fstab restrict].  
}}
}}


=== Loop directive with independent clause ===
 
Another way to tell the compiler that loops iterations are independent is to specify it explicitly by using a different directive: <tt>loop</tt>, with the clause <tt>independent</tt>. This is a ''prescriptive'' directive. Like any prescriptive directive, this tells the compiler what to do, and overrides any compiler analysis. The initial example above would become:  
=== Boucle avec clause <tt>independent</tt> ===
Une autre façon de s'assurer que le compilateur traite les boucles de manière indépendante est de le spécifier explicitement  avec la clause <tt>independent</tt>. Comme toute autre directive ''prescriptive'', le compilateur y est obligé et l'analyse qu'il pourrait faire ne sera pas considérée. En reprenant l'exemple de la section ''La directive <tt>kernels</tt>'' ci-dessus, nous avons&nbsp;:
<syntaxhighlight lang="cpp" line highlight="3">
<syntaxhighlight lang="cpp" line highlight="3">
#pragma acc kernels
#pragma acc kernels
Line 195: Line 210:
</syntaxhighlight>  
</syntaxhighlight>  


=== Back to the example ===
=== Produit matrice-vecteur ===  
Going back to the matrix-vector product above, the way that we recommend fixing false aliasing is by declaring the pointers as restricted. This is done by changing the following code in <tt>matrix_functions.h</tt>:
Revenons au cas du produit matrice-vecteur présenté plus haut. Notre recommandation pour éviter les faux alias est de définir les pointeurs comme étant restreints en remplaçant le code de <tt>matrix_functions.h</tt>.
<syntaxhighlight lang="cpp" line>
<syntaxhighlight lang="cpp" line>
   double *Acoefs=A.coefs;
   double *Acoefs=A.coefs;
Line 202: Line 217:
   double *ycoefs=y.coefs;
   double *ycoefs=y.coefs;
</syntaxhighlight>  
</syntaxhighlight>  
by this code:
par le code  
<syntaxhighlight lang="cpp" line>
<syntaxhighlight lang="cpp" line>
   double *__restrict Acoefs=A.coefs;
   double *__restrict Acoefs=A.coefs;
Line 208: Line 223:
   double *__restrict ycoefs=y.coefs;
   double *__restrict ycoefs=y.coefs;
</syntaxhighlight>  
</syntaxhighlight>  
We note that we do not need to declare the other pointers as restricted, since they are not reported as problematic by the compiler. With the above changes, recompiling gives the following compiler messages:  
Remarquez que les autres pointeurs n'ont pas besoin d'être restreints puisque le compilateur ne les rapporte pas comme causant des problèmes. En recompilant avec les changements que nous venons de faire, le compilateur émet le message suivant&nbsp;:  
{{Command
{{Command
|pgc++ -fast -Minfo{{=}}accel -ta{{=}}tesla:managed main.cpp -o challenge
|nvc++ -fast -Minfo{{=}}accel -acc -gpu{{=}}managed main.cpp -o challenge
|result=
|result=
matvec(const matrix &, const vector &, const vector &):
matvec(const matrix &, const vector &, const vector &):
     23, include "matrix_functions.h"
     23, include "matrix_functions.h"
           27, Generating copyout(ycoefs[:num_rows])
           27, Generating implicit copyout(ycoefs[:num_rows]) [if not already present]
               Generating copyin(xcoefs[:],Acoefs[:],cols[:],row_offsets[:num_rows+1])
               Generating implicit copyin(xcoefs[:],row_offsets[:num_rows+1],Acoefs[:],cols[:]) [if not already present]
           29, Loop is parallelizable
           30, Loop is parallelizable
              Accelerator kernel generated
               Generating Tesla code
               Generating Tesla code
               29, #pragma acc loop gang, vector(128) /* blockIdx.x threadIdx.x */
               30, #pragma acc loop gang /* blockIdx.x */
           33, Loop is parallelizable
              34, #pragma acc loop vector(128) /* threadIdx.x */
                  Generating implicit reduction(+:sum)
           34, Loop is parallelizable
}}
}}


== How is ported code performing ? ==
== Performance du code porté ==  
Since we have completed a first step to porting the code to GPU, we need to analyze how the code is performing, and whether it gives the correct results. Running the original version of the code yields the following (performed on one of Guillimin's GPU node):
Maintenant que le code est porté sur le GPU, nous pouvons analyser sa performance et vérifier si les résultats sont corrects. L'exécution du code original sur un nœud GPU produit ceci&nbsp;:
{{Command
{{Command
|./cg.x  
|./cg.x  
Line 242: Line 258:
}}
}}


Running the OpenACC version yields the following:
Voici le résultat pour la version OpenACC&nbsp;:
{{Command
{{Command
|./challenge  
|./challenge  
Line 258: Line 274:
Total Iterations: 100 Total Time: 115.068931s
Total Iterations: 100 Total Time: 115.068931s
}}
}}
[[File:Openacc profiling1.png|thumbnail|Click to enlarge]]
[[File:Openacc profiling1.png|thumbnail|Cliquez pour agrandir.]]
The results are correct. However, not only do we not get any speed up, but we rather get a slow down by a factor of almost 4! Let's profile the code again using NVidia's visual profiler (<tt>nvvp</tt>). This can be done with the following steps:
Les résultats sont corrects,  toutefois, loin de gagner en vitesse, l'opération a pris près de quatre fois plus de temps! Utilisons le NVidia Visual Profiler (<tt>nvvp</tt>) pour voir ce qui se passe.
# Start <tt>nvvp</tt> with the command <tt>nvvp &</tt>  (the <tt>&</tt> sign is to start it in the background)
# Go in File -> New Session
# In the "File:" field, search for the executable (named <tt>challenge</tt> in our example).
# Click "Next" until you can click "Finish".  


This will run the program and generate a timeline of the execution. The resulting timeline is illustrated on the image on the right side. As we can see, almost all of the run time is being spent transferring data between the host and the device. This is very often the case when one ports a code from CPU to GPU. We will look at how to optimize this in the [[OpenACC Tutorial - Data movement|next part of the tutorial]].
=== NVIDIA Visual Profiler ===
[[File:Nvvp-pic0.png|thumbnail|300px|Profileur NVVP|right]]
[[File:Nvvp-pic1.png|thumbnail|300px|Recherche de l'exécutable que vous voulez profiler|right]]


== The <tt>parallel loop</tt> directive ==
[https://developer.nvidia.com/nvidia-visual-profiler NVIDIA Visual Profiler (NVVP)] est un profileur graphique pour les applications OpenACC.
With the <tt>kernels</tt> directive, we let the compiler do all of the analysis. This is the ''descriptive'' approach to porting a code. OpenACC supports a ''prescriptive'' approach through a different directive, called the <tt>parallel</tt> directive. This can be combined with the <tt>loop</tt> directive, to form the <tt>parallel loop</tt> directive. An example would be the following code:
C'est un outil d'analyse pour les '''codes écrits avec les directives OpenACC et CUDA C/C++'''.
En conséquence, si l'exécutable n'utilise pas le GPU, ce profileur ne fournira aucun résultat.
 
Quand [[Visualization#Fenêtres_à_distance_avec_redirection_X11|X11 est redirigé vers un serveur X-Server]] ou quand vous utilisez un [[VNC/fr|environnement bureau Linux]] (aussi via [[JupyterHub/fr#Bureau|JupyterHub]] avec 2 cœurs CPU, 5000M de mémoire et 1 GPU),
vous pouvez lancer NVVP à partir d'un terminal&nbsp;:
{{Command
|module load cuda/11.7 java/1.8
}}
{{Command
|nvvp
}}
 
# Après l'affichage de la fenêtre de lancement de NVVP, vous devez entrer le répertoire ''Workspace'' qui sera employé pour les fichiers temporaires. Dans le chemin suggéré, remplacez <code>home</code> par <code>scratch</code> et cliquez sur ''OK''.
# Sélectionnez ''File > New Session'' ou cliquez sur le bouton correspondant dans la barre d'outils.
# Cliquez sur le bouton ''Browse'' à la droite du champ ''File'' pour le chemin.
## Changez le répertoire s'il y a lieu.
## Sélectionnez un exécutable construit avec des codes écrits avec des directives OpenACC et CUDA C/C++.
# Sous le champ ''Arguments'', sélectionnez l'option ''Profile current process only''.
# Cliquez sur ''Next >'' pour voir les autres options de profilage.
# Cliquez sur ''Finish'' pour lancer le profilage de l'exécutable.
 
Pour faire ceci, suivez ces étapes&nbsp;:
# Lancez <tt>nvvp</tt> avec la commande <tt>nvvp &</tt>  (le symbole <tt>&</tt> commande le lancement en arrière-plan).
# Sélectionnez '' File -> New Session''.
# Dans le champ ''File:'', cherchez l'exécutable (nommé dans notre exemple <tt>challenge</tt>).
# Cliquez sur ''Next'' jusqu'à ce que vous puissiez cliquer sur ''Finish''.
 
Le programme est exécuté et on obtient un tableau chronologique du déroulement (voir l'image). On remarque que le transfert de données entre le départ et l'arrivée occupe la plus grande partie du temps d'exécution, ce qui est fréquent quand du code est porté d'un CPU vers un GPU. Nous verrons comment ceci peut être amélioré dans la prochaine partie, [[OpenACC Tutorial - Data movement/fr|Mouvement des données]].
 
== La directive <tt>parallel loop</tt> ==  
Avec la directive <tt>kernels</tt>, c'est le compilateur qui fait toute l'analyse; ceci est une approche ''descriptive'' pour porter du code. OpenACC offre aussi une approche ''prescriptive'' avec la directive <tt>parallel</tt> qui peut être combinée à la directive <tt>loop</tt> ainsi&nbsp;:
<syntaxhighlight lang="cpp" line highlight="1">
<syntaxhighlight lang="cpp" line highlight="1">
#pragma acc parallel loop
#pragma acc parallel loop
Line 276: Line 320:
}
}
</syntaxhighlight>  
</syntaxhighlight>  
Since <tt>parallel loop</tt> is a ''prescriptive'' directive, it forces the compiler to perform the loop in parallel. This means that the <tt>independent</tt> clause introduced above is implicit within a parallel region.  
Comme <tt>parallel loop</tt> est une directive ''prescriptive'', le compilateur est forcé d'exécuter la boucle en parallèle. Ceci signifie que la clause <tt>independent</tt> mentionnée plus haut est implicite à l'intérieur d'une zone parallèle.  


For reasons that we explain below, in order to use this directive in the matrix-vector product example, we need to introduce additional clauses used to manage the scope of data. The <tt>private</tt> and <tt>reduction</tt> clauses control how the data flows through a parallel region.  
Pour utiliser cette directive dans notre exemple du produit matrice-vecteur, nous avons besoin des clauses <tt>private</tt> et <tt>reduction</tt> pour gérer le flux des données dans la zone parallèle.  
* With the <tt>private</tt> clause, a copy of the variable is made for each loop iteration, making the value of the variable independent from other iterations.  
* Avec la clause <tt>private</tt>, une copie de la variable est faite pour chaque itération de la boucle; la valeur de la variable est ainsi indépendante des autres itérations.  
* With the <tt>reduction</tt> clause, the values of a variable in each iteration will be ''reduced'' to a single value. It supports addition (+), multiplication (*), maximum (max), minimum (min), among other operations.  
* Avec la clause <tt>reduction</tt>, les valeurs de la variable dans chaque itération est ''réduite'' à une valeur unique. La clause s'utilise entre autres avec les opérations addition (+), multiplication (*), maximum (max) et minimum (min).  
These clauses were not required with the <tt>kernels</tt> directive, because the <tt>kernels</tt> directive handles this for you.  
Ces clauses ne sont pas nécessaires avec la directive <tt>kernels</tt> puisque celle-ci fait le travail pour vous.  


Going back to the matrix-vector multiplication example, the corresponding code with the <tt>parallel loop</tt> directive would look like this:
Reprenons l'exemple de produit matrice-vecteur avec la directive <tt>parallel loop</tt>;
<syntaxhighlight lang="cpp" line highlight="6">
<syntaxhighlight lang="cpp" line highlight="6">
#pragma acc parallel loop
#pragma acc parallel loop
Line 300: Line 344:
   }
   }
</syntaxhighlight>
</syntaxhighlight>
Compiling this code yields the following compiler feedback:
La compilation produit le message suivant&nbsp;:
{{Command
{{Command
|pgc++ -fast -Minfo{{=}}accel -ta{{=}}tesla:managed main.cpp -o challenge
|nvc++ -fast -Minfo{{=}}accel -acc -gpu{{=}}managed main.cpp -o challenge
|result=
|result=
matvec(const matrix &, const vector &, const vector &):
matvec(const matrix &, const vector &, const vector &):
Line 316: Line 360:
}}
}}


== Parallel loop vs kernel ==
==Différences entre <tt>parallel loop</tt> et <tt>kernels</tt>==
{| class="wikitable" width="100%"
{| class="wikitable" width="100%"
|-
|-
! PARALLEL LOOP  !! KERNEL
! PARALLEL LOOP  !! KERNELS
|-
|-
|
|
   
   
* It is the programmer's responsibility to ensure that parallelism is safe
* l'intégrité du code parallélisé revient au programmeur
* Enables parallelization of sections that the compiler may miss
* le programmeur voit la parallélisation que le compilateur pourrait manquer
* Straightforward path from OpenMP
* le fonctionnement est identique en OpenMP
||  
||  
* It is the compiler's responsibility to analyze the code and determine what is safe to parallelize.
* la responsabilité d'analyser le code et de garantir son intégrité revient au compilateur
* A single directive can cover a large area of code
* une seule directive peut s'appliquer à une grande portion de code  
* The compiler has more room to optimize
* le compilateur est libre d'optimiser le code
|}
|}
Both approaches are equally valid and can perform equally well.
Les deux approches sont valides et leur performance est comparable.


  {{Challenge
   
|title=Challenge: Add OpenACC directives <tt>kernels</tt> or <tt>parallel loop</tt>  
{{Challenge
|title=Exercice : utiliser <tt>kernels</tt> ou <tt>parallel loop</tt>  
|content=
|content=
# Modify the functions <tt>matvec</tt>, <tt>waxpby</tt> and <tt>dot</tt> to use OpenACC. You may use either the <tt>kernels</tt> or the <tt>parallel loop</tt> directives. The directories <tt>step1.*</tt> contain the solution.  
# Modifiez les fonctions <tt>matvec</tt>, <tt>waxpby</tt> et <tt>dot</tt>. Vous pouvez utiliser soit <tt>kernels</tt>, soit <tt>parallel loop</tt>. La solution se trouve dans les répertoires <tt>step1. de [https://github.com/calculquebec/cq-formation-openacc Github] *</tt>.  
# Modify the Makefile to add <tt>-ta{{=}}tesla:managed</tt> and <tt>-Minfo{{=}}accel</tt> to your compiler flags.  
# Modifiez le Makefile en ajoutant <tt>-acc -gpu{{=}}managed</tt> et <tt>-Minfo{{=}}accel</tt> aux indicateurs pour le compilateur.  
}}
}}


[[OpenACC Tutorial - Data movement|Onward to the next unit: Data movement]]<br>
[[OpenACC Tutorial - Profiling/fr|<- Page précédente, Profileurs]] | [[OpenACC Tutorial/fr|^- Retour au début du tutoriel]] | [[OpenACC Tutorial - Data movement/fr|Page suivante, Mouvement des données ->]]
[[OpenACC Tutorial|Back to the lesson plan]]

Latest revision as of 19:37, 8 June 2023

Other languages:


Objectifs d'apprentissage
  • comprendre le processus de transfert (offloading)
  • comprendre ce qu'est une directive OpenACC
  • connaitre la différence entre les directives loop et kernels
  • savoir programmer avec OpenACC
  • comprendre le concept d'alias en C/C++
  • savoir utiliser la rétroaction du compilateur et éviter les faux alias


Transfert vers un processeur graphique (GPU)

Avant de porter du code sur un GPU, il faut savoir que ceux-ci ne partagent pas la même mémoire que le CPU de l'hôte.

  • la mémoire de l'hôte est en général plus grande, mais plus lente que la mémoire du GPU;
  • un GPU n'a pas d'accès direct à la mémoire de l'hôte;
  • pour pouvoir utiliser un GPU, les données doivent passer par le bus PCI, dont la bande passante est moins grande que celles du CPU et du GPU;
  • il est donc de la plus haute importance de bien gérer les transferts entre la mémoire de départ et le GPU. En anglais, ce processus s'appelle offloading.

Directives OpenACC

Les directives OpenAcc sont semblables aux directives OpenMP. En C/C++, ce sont des énoncés pragmas et en Fortran, des commentaires. L'emploi de directives comporte plusieurs avantages :

  • Premièrement, puisque le code est peu affecté, les modifications peuvent se faire de manière incrémentale, un pragma à la fois; ceci est particulièrement utile pour le débogage puisqu'il est ainsi facile d'identifier le changement précis qui crée le bogue.
  • Deuxièmement, OpenACC peut être désactivé au moment de la compilation; les pragmas sont alors vus comme étant des commentaires et ne sont pas considérés par le compilateur, ce qui permet de compiler une version accélérée et une version normale à partir du même code source.
  • Troisièmement, comme le compilateur fait tout le travail de transfert, le même code peut être compilé pour différents types d'accélérateurs, que ce soit un GPU ou des instructions SIMD sur un CPU; ainsi, un changement du matériel exigera simplement la mise à jour du compilateur, sans modification au code.

Le code de notre exemple contient deux boucles : la première initialise deux vecteurs et la seconde effectue une opération de niveau 1 d'addition des vecteurs.

C/C++ FORTRAN
#pragma acc kernels
{
  for (int i=0; i<N; i++)
  {
    x[i] = 1.0;
    y[i] = 2.0;
  }

  for (int i=0; i<N; i++)
  {
    y[i] = a * x[i] + y[i];
  }
}
!$acc kernels
  do i=1,N
    x(i) = 1.0
    y(i) = 2.0
  end do
  
  y(:) = a*x(:) + y(:)
!$acc end kernels

Dans les deux cas, le compilateur identifie deux noyaux (kernels) :

  • en C/C++, les deux noyaux sont à l'intérieur de chaque boucle.;
  • en Fortran, les noyaux sont à l'intérieur de la première boucle et à l'intérieur de la boucle implicite effectuée lors d'une opération sur des tableaux.

Remarquez que le bloc OpenACC est délimité en C/C++ par des accolades; en Fortran, le commentaire est placé une fois au début et une dernière fois à la fin, avec l'ajout cette fois de end.

Boucles et noyaux

Quand le compilateur lit la directive OpenACC kernels, il analyse le code pour identifier les sections pouvant être parallélisées. Ceci correspond souvent au corps d'une boucle qui a des itérations indépendantes. Dans ce cas, le compilateur délimite le début et la fin du corps du code avec la fonction kernel. Les appels à cette fonction ne seront pas affectés par les autres appels. La fonction est compilée et peut ensuite être exécutée sur un accélérateur. Comme chaque appel est indépendant, chacun des centaines de cœurs de l'accélérateur peut exécuter la fonction en parallèle pour un index spécifique.

BOUCLE KERNEL
for (int i=0; i<N; i++)
{
  C[i] = A[i] + B[i];
}
void kernelName(A, B, C, i)
{
  C[i] = A[i] + B[i];
}
Calcule séquentiellement de i=0 à i=N-1, inclusivement. Chaque unité de calcul exécute la fonction pour une seule valeur de i.

La directive kernels

Cette directive est dite descriptive. Le programmeur l'utilise pour signifier au compilateur les portions qui selon lui peuvent être parallélisées. Le compilateur fait ce qu'il veut de cette information et adopte la stratégie qui lui semble la meilleure pour exécuter le code, incluant son exécution séquentielle. De façon générale, le compilateur

  1. analyse le code pour détecter le parallélisme,
  2. s'il détecte du parallélisme, identifie les données à transférer et décide quand faire le transfert,
  3. crée un kernel,
  4. transfère le kernel au GPU.

Voici un exemple de cette directive :

#pragma acc kernels
{
  for (int i=0; i<N; i++)
  {
    C[i] = A[i] + B[i];
  }
}

Il est rare que le code soit aussi simple et il faut se baser sur la rétroaction du compilateur pour trouver les portions qu'il a négligé de paralléliser.


Description ou prescription

Si vous avez déjà utilisé OpenMP, vous retrouverez dans OpenACC le principe de directives. Il existe cependant d'importantes différences entre les directives OpenMP et OpenACC :

  • Les directives OpenMP sont à la base prescriptives. Ceci signifie que le compilateur est forcé d'accomplir la parallélisation, peu importe que l'effet détériore ou améliore la performance. Le résultat est prévisible pour tous les compilateurs. De plus, la parallélisation se fera de la même manière, peu importe le matériel utilisé pour exécuter le code. Par contre, le même code peut connaitre une moins bonne performance, dépendant de l'architecture. Il peut donc être préférable par exemple de changer l'ordre des boucles. Pour paralléliser du code avec OpenMP et obtenir une performance optimale dans différentes architectures, il faudrait avoir un ensemble différent de directives pour chaque architecture.
  • Pour leur part, plusieurs directives OpenACC sont de nature descriptive. Ici, le compilateur est libre de compiler le code de la façon qu'il juge la meilleure, selon l'architecture visée. Dans certains cas, le code ne sera pas parallélisé du tout. Le même code exécuté sur un GPU ou sur un CPU peut donner du code binaire différent. Ceci signifie que la performance pourrait varier selon le compilateur et que les compilateurs d'une nouvelle génération seront plus efficaces, surtout en présence de nouveau matériel.


Exemple : porter un produit matrice-vecteur

Pour notre exemple, nous utilisons du code provenant du répertoire Github, particulièrement une portion de code fichier cpp/matrix_functions.h. Le code Fortran équivalent se trouve dans la sous-routine matvec contenue dans le fichier matrix.F90. Le code C++ est comme suit :

  for(int i=0;i<num_rows;i++) {
    double sum=0;
    int row_start=row_offsets[i];
    int row_end=row_offsets[i+1];
    for(int j=row_start;j<row_end;j++) {
      unsigned int Acol=cols[j];
      double Acoef=Acoefs[j];
      double xcoef=xcoefs[Acol];
      sum+=Acoef*xcoef;
    }
    ycoefs[i]=sum;
  }

Le premier changement à faire au code est d'ajouter la directive kernels pour essayer de le faire exécuter sur le GPU. Pour l'instant, nous n'avons pas à nous préoccuper du transfert des données ou à fournir des renseignements au compilateur.

#pragma acc kernels
  {
    for(int i=0;i<num_rows;i++) {
      double sum=0;
      int row_start=row_offsets[i];
      int row_end=row_offsets[i+1];
      for(int j=row_start;j<row_end;j++) {
        unsigned int Acol=cols[j];
        double Acoef=Acoefs[j];
        double xcoef=xcoefs[Acol];
        sum+=Acoef*xcoef;
      }
      ycoefs[i]=sum;
    }
  }

Construire avec OpenACC

Les compilateurs NVidia utilisent l'option -acc pour permettre la compilation pour un accélérateur. Nous utilisons la sous-option -gpu=managed pour indiquer au compilateur que nous voulons utiliser la mémoire gérée pour simplifier le transfert de données en provenance et à destination du périphérique; nous n'utiliserons pas cette option dans un prochain exemple. Nous utilisons aussi l'option -fast pour l'optimisation.

 
[name@server ~]$ nvc++ -fast -Minfo=accel -acc -gpu=managed main.cpp -o challenge
...
matvec(const matrix &, const vector &, const vector &):
     23, include "matrix_functions.h"
          30, Generating implicit copyin(cols[:],row_offsets[:num_rows+1],Acoefs[:]) [if not already present]
              Generating implicit copyout(ycoefs[:num_rows]) [if not already present]
              Generating implicit copyin(xcoefs[:]) [if not already present]
          31, Loop carried dependence of ycoefs-> prevents parallelization
              Loop carried backward dependence of ycoefs-> prevents vectorization
              Complex loop carried dependence of Acoefs->,xcoefs-> prevents parallelization
              Generating NVIDIA GPU code
              31, #pragma acc loop seq
              35, #pragma acc loop vector(128) /* threadIdx.x */
                  Generating implicit reduction(+:sum)
          35, Loop is parallelizable

Le résultat montre que la boucle externe sur la ligne 31 n'a pas pu être parallélisée par le compilateur. Dans la prochaine section, nous expliquons comment traiter ces dépendances.

Réparer les fausses dépendances de boucles

Même lorsque le programmeur sait qu'une boucle peut être parallélisée, il arrive que le compilateur ne le remarque pas. Un cas commun en C/C++ est connu sous le nom de pointer aliasing. Contrairement au Fortran, C/C++ ne possèdent pas comme tel de tableaux (arrays), mais plutôt des pointeurs. Le concept d'alias s'applique à deux pointeurs dirigés vers la même mémoire. Si le compilateur ne sait pas que des pointeurs ne sont pas des alias, il doit cependant le supposer. Dans l'exemple précédent, on voit clairement pourquoi le compilateur ne pouvait pas paralléliser la boucle. En supposant que les pointeurs sont identiques, il y a forcément dépendance des itérations de la boucle.

Mot-clé restrict

Une des manières de dire au compilateur que les pointeurs ne sont pas des alias est d'utiliser le mot-clé restrict, introduit à cette fin dans C99. Il n'y a toujours pas de manière standard pour ce faire en C++, mais chaque compilateur possède un mot-clé qui lui est propre. Dépendant du compilateur, on peut utiliser __restrict ou __restrict__. Les compilateurs du Portland Group et de NVidia utilisent __restrict. Pour savoir pourquoi il n'existe pas de standard en C++, consultez ce document. Ce concept est important pour OpenACC comme pour toute programmation C/C++, car les compilateurs peuvent effectuer plusieurs autres optimisations si les pointeurs ne sont pas des alias. Remarquez que le mot-clé se place après le pointeur puisque c'est à ce dernier qu'il se réfère, et non au type; autrement dit, la déclaration doit se lire float * __restrict A; plutôt que float __restrict * A;.


Utilisation du mot-clé restrict

En déclarant un pointeur comme étant restreint, on s'assure qu'uniquement ce pointeur ou une valeur dérivée (comme ptr +1) pourra accéder à l'objet auquel il réfère, et ce pour la durée de vie du pointeur. Ceci est une garantie que le programmeur donne au compilateur; si le programmeur manque à son obligation, le comportement n'est pas défini. Pour plus d'information, consultez l'article Wikipédia restrict.



Boucle avec clause independent

Une autre façon de s'assurer que le compilateur traite les boucles de manière indépendante est de le spécifier explicitement avec la clause independent. Comme toute autre directive prescriptive, le compilateur y est obligé et l'analyse qu'il pourrait faire ne sera pas considérée. En reprenant l'exemple de la section La directive kernels ci-dessus, nous avons :

#pragma acc kernels
{
#pragma acc loop independent
for (int i=0; i<N; i++)
{
  C[i] = A[i] + B[i];
}
}

Produit matrice-vecteur

Revenons au cas du produit matrice-vecteur présenté plus haut. Notre recommandation pour éviter les faux alias est de définir les pointeurs comme étant restreints en remplaçant le code de matrix_functions.h.

  double *Acoefs=A.coefs;
  double *xcoefs=x.coefs;
  double *ycoefs=y.coefs;

par le code

  double *__restrict Acoefs=A.coefs;
  double *__restrict xcoefs=x.coefs;
  double *__restrict ycoefs=y.coefs;

Remarquez que les autres pointeurs n'ont pas besoin d'être restreints puisque le compilateur ne les rapporte pas comme causant des problèmes. En recompilant avec les changements que nous venons de faire, le compilateur émet le message suivant :

 
[name@server ~]$ nvc++ -fast -Minfo=accel -acc -gpu=managed main.cpp -o challenge
matvec(const matrix &, const vector &, const vector &):
     23, include "matrix_functions.h"
          27, Generating implicit copyout(ycoefs[:num_rows]) [if not already present]
              Generating implicit copyin(xcoefs[:],row_offsets[:num_rows+1],Acoefs[:],cols[:]) [if not already present]
          30, Loop is parallelizable
              Generating Tesla code
              30, #pragma acc loop gang /* blockIdx.x */
              34, #pragma acc loop vector(128) /* threadIdx.x */
                  Generating implicit reduction(+:sum)
          34, Loop is parallelizable

Performance du code porté

Maintenant que le code est porté sur le GPU, nous pouvons analyser sa performance et vérifier si les résultats sont corrects. L'exécution du code original sur un nœud GPU produit ceci :

 
[name@server ~]$ ./cg.x 
Rows: 8120601, nnz: 218535025
Iteration: 0, Tolerance: 4.0067e+08
Iteration: 10, Tolerance: 1.8772e+07
Iteration: 20, Tolerance: 6.4359e+05
Iteration: 30, Tolerance: 2.3202e+04
Iteration: 40, Tolerance: 8.3565e+02
Iteration: 50, Tolerance: 3.0039e+01
Iteration: 60, Tolerance: 1.0764e+00
Iteration: 70, Tolerance: 3.8360e-02
Iteration: 80, Tolerance: 1.3515e-03
Iteration: 90, Tolerance: 4.6209e-05
Total Iterations: 100 Total Time: 29.894881s

Voici le résultat pour la version OpenACC :

 
[name@server ~]$ ./challenge 
Rows: 8120601, nnz: 218535025
Iteration: 0, Tolerance: 4.0067e+08
Iteration: 10, Tolerance: 1.8772e+07
Iteration: 20, Tolerance: 6.4359e+05
Iteration: 30, Tolerance: 2.3202e+04
Iteration: 40, Tolerance: 8.3565e+02
Iteration: 50, Tolerance: 3.0039e+01
Iteration: 60, Tolerance: 1.0764e+00
Iteration: 70, Tolerance: 3.8360e-02
Iteration: 80, Tolerance: 1.3515e-03
Iteration: 90, Tolerance: 4.6209e-05
Total Iterations: 100 Total Time: 115.068931s
 
Cliquez pour agrandir.

Les résultats sont corrects, toutefois, loin de gagner en vitesse, l'opération a pris près de quatre fois plus de temps! Utilisons le NVidia Visual Profiler (nvvp) pour voir ce qui se passe.

NVIDIA Visual Profiler

 
Profileur NVVP
 
Recherche de l'exécutable que vous voulez profiler

NVIDIA Visual Profiler (NVVP) est un profileur graphique pour les applications OpenACC. C'est un outil d'analyse pour les codes écrits avec les directives OpenACC et CUDA C/C++. En conséquence, si l'exécutable n'utilise pas le GPU, ce profileur ne fournira aucun résultat.

Quand X11 est redirigé vers un serveur X-Server ou quand vous utilisez un environnement bureau Linux (aussi via JupyterHub avec 2 cœurs CPU, 5000M de mémoire et 1 GPU), vous pouvez lancer NVVP à partir d'un terminal :

 
[name@server ~]$ module load cuda/11.7 java/1.8
 
[name@server ~]$ nvvp
  1. Après l'affichage de la fenêtre de lancement de NVVP, vous devez entrer le répertoire Workspace qui sera employé pour les fichiers temporaires. Dans le chemin suggéré, remplacez home par scratch et cliquez sur OK.
  2. Sélectionnez File > New Session ou cliquez sur le bouton correspondant dans la barre d'outils.
  3. Cliquez sur le bouton Browse à la droite du champ File pour le chemin.
    1. Changez le répertoire s'il y a lieu.
    2. Sélectionnez un exécutable construit avec des codes écrits avec des directives OpenACC et CUDA C/C++.
  4. Sous le champ Arguments, sélectionnez l'option Profile current process only.
  5. Cliquez sur Next > pour voir les autres options de profilage.
  6. Cliquez sur Finish pour lancer le profilage de l'exécutable.

Pour faire ceci, suivez ces étapes :

  1. Lancez nvvp avec la commande nvvp & (le symbole & commande le lancement en arrière-plan).
  2. Sélectionnez File -> New Session.
  3. Dans le champ File:, cherchez l'exécutable (nommé dans notre exemple challenge).
  4. Cliquez sur Next jusqu'à ce que vous puissiez cliquer sur Finish.

Le programme est exécuté et on obtient un tableau chronologique du déroulement (voir l'image). On remarque que le transfert de données entre le départ et l'arrivée occupe la plus grande partie du temps d'exécution, ce qui est fréquent quand du code est porté d'un CPU vers un GPU. Nous verrons comment ceci peut être amélioré dans la prochaine partie, Mouvement des données.

La directive parallel loop

Avec la directive kernels, c'est le compilateur qui fait toute l'analyse; ceci est une approche descriptive pour porter du code. OpenACC offre aussi une approche prescriptive avec la directive parallel qui peut être combinée à la directive loop ainsi :

#pragma acc parallel loop
for (int i=0; i<N; i++)
{
  C[i] = A[i] + B[i];
}

Comme parallel loop est une directive prescriptive, le compilateur est forcé d'exécuter la boucle en parallèle. Ceci signifie que la clause independent mentionnée plus haut est implicite à l'intérieur d'une zone parallèle.

Pour utiliser cette directive dans notre exemple du produit matrice-vecteur, nous avons besoin des clauses private et reduction pour gérer le flux des données dans la zone parallèle.

  • Avec la clause private, une copie de la variable est faite pour chaque itération de la boucle; la valeur de la variable est ainsi indépendante des autres itérations.
  • Avec la clause reduction, les valeurs de la variable dans chaque itération est réduite à une valeur unique. La clause s'utilise entre autres avec les opérations addition (+), multiplication (*), maximum (max) et minimum (min).

Ces clauses ne sont pas nécessaires avec la directive kernels puisque celle-ci fait le travail pour vous.

Reprenons l'exemple de produit matrice-vecteur avec la directive parallel loop;

#pragma acc parallel loop
  for(int i=0;i<num_rows;i++) {
    double sum=0;
    int row_start=row_offsets[i];
    int row_end=row_offsets[i+1];
#pragma acc loop reduction(+:sum)
    for(int j=row_start;j<row_end;j++) {
      unsigned int Acol=cols[j];
      double Acoef=Acoefs[j];
      double xcoef=xcoefs[Acol];
      sum+=Acoef*xcoef;
    }
    ycoefs[i]=sum;
  }

La compilation produit le message suivant :

 
[name@server ~]$ nvc++ -fast -Minfo=accel -acc -gpu=managed main.cpp -o challenge
matvec(const matrix &, const vector &, const vector &):
     23, include "matrix_functions.h"
          27, Accelerator kernel generated
              Generating Tesla code
              29, #pragma acc loop gang /* blockIdx.x */
              34, #pragma acc loop vector(128) /* threadIdx.x */
                  Sum reduction generated for sum
          27, Generating copyout(ycoefs[:num_rows])
              Generating copyin(xcoefs[:],Acoefs[:],cols[:],row_offsets[:num_rows+1])
          34, Loop is parallelizable

Différences entre parallel loop et kernels

PARALLEL LOOP KERNELS
  • l'intégrité du code parallélisé revient au programmeur
  • le programmeur voit la parallélisation que le compilateur pourrait manquer
  • le fonctionnement est identique en OpenMP
  • la responsabilité d'analyser le code et de garantir son intégrité revient au compilateur
  • une seule directive peut s'appliquer à une grande portion de code
  • le compilateur est libre d'optimiser le code

Les deux approches sont valides et leur performance est comparable.


Exercice : utiliser kernels ou parallel loop
  1. Modifiez les fonctions matvec, waxpby et dot. Vous pouvez utiliser soit kernels, soit parallel loop. La solution se trouve dans les répertoires step1. de Github *.
  2. Modifiez le Makefile en ajoutant -acc -gpu=managed et -Minfo=accel aux indicateurs pour le compilateur.


<- Page précédente, Profileurs | ^- Retour au début du tutoriel | Page suivante, Mouvement des données ->