Personal tools
You are here: Home Calcul Technique Documentation IBM cluster p690 (Power4) Conseils Pratiques
Document Actions

Conseils Pratiques

Mise en oeuvre de quelques optimisations algorithmiques

  • Type de données pour les flottants
  • Mesure du temps dans les programmes
  • Type de données MPI pour les flottants
  • Générateur de nombres pseudo-aléatoires et MPI/OpenMP
  • Bibliothèques scientifiques
  • Recopiage des données
  • Calcul des puissances
  • Evaluation polynômiale
  • Arithmétique entière
  • Calculs des constantes
  • Tests arithmétiques flottants selon la précision machine
  • Sauvegarde et reprise

  • Introduction

    Cette page contient quelques exemples de choses à éviter ou à mieux écrire .... et propose une solution.
    Elle essaye aussi de donner des conseils pour la portabilité des applications.
    Pour tout problème ou question, n'hésitez pas à ouvrir un ticket d'assistance.

    Type de données pour les flottants

    Le type de données pour les nombres réels, appelés aussi flottants, peut réserver des surprises; en effet, pour un même type, on n'a pas forcément la même précision selon la machine utilisée car parfois la signification n'est pas la même.
    Tout d'abord, partons du principe que l'on souhaite travailler avec environ 15 décimales, soit avec le type DOUBLE PRECISION en général, sauf (liste non exhaustive) pour les machines Cray où ce n'est par la même chose ...
    A titre d'exemple, considérons la représentation des données sur un Cray T3E, une SGI Origin 2000 et un IBM p690.
     
    REAL
    DOUBLE PRECISION
    Cray T3E
    8 octets
    16 octets
    SGI Origin 2000
    4 octets
    8 octets
    IBM p690
    4 octets
    8 octets

    Ainsi le même code avec le type DOUBLE PRECISION n'aura pas le même comportement; en effet ce choix a plusieurs conséquences :
    - les données prennent deux fois plus de place en mémoire,
    - une part importantes des fonctions mathématiques ne supporte pas le type,
    - des travaux de re-écriture sont nécessaires.
    Ainsi, on peut obtenir ce genre de messages à la compilation :

    cf90-1110 f90: WARNING CODE, File = calcul.f, Line = 403, Column = 32
      DOUBLE PRECISION is not supported on this platform.  REAL will be used.

            TL = 1.d0/(1.d0+TT)
     

    Une alternative est de travailler avec un type, dépendant d'un paramètre qui est la seule valeur à modifier pour tout le code.
    Par exemple en Fortran 90, les déclarations suivantes permettent de construire le paramètre :

    MODULE numerics
     INTEGER,  PARAMETER  ::  sp = kind(1.0  )  ! nombre d'octets pour de la simple precision
     INTEGER,  PARAMETER  ::  dp = kind(1.0D0)  ! nombre d'octets pour de la double precision
     INTEGER,  PARAMETER  ::  rp = dp
    END MODULE numerics

    On peut insérer ces lignes dans un module que l'on employe dans chaque sous programme par l'instruction use.
    La déclaration des variables est alors la suivante :

    use numerics
     INTEGER    ::  i = 7
     REAL(rp)   ::  epsas = 1E-5_rp
     REAL(rp)   ::  srs6  = 6.0_rp
     REAL(rp),  DIMENSION(10)  ::  vxa

    L'utilisation de constantes scalaires, d'entiers dans des expressions flottantes s'exprime alors :
     vxa(i) = SQRT(24.0_rp) * epsas * srs6 * (2.0_rp * srs6 - 1.0_rp) / REAL(i, rp)

    Les valeurs réelles sont toutes calculées selon la précision souhaitée et une seule modification est nécessaire.
    De plus, la compilation se fait sans option concernant la précision des flottants, i.e. pas de -r8, -r4 ou autre, la déclaration impose le type.

    Retour au début de la page.


    Mesure du temps dans les programmes

    Il existe plusieurs fonctions Fortran qui permettent de mesurer le temps écoulé.
    • timef :
    La function timef renvoie le temps écoulé (real) en millisecondes depuis le premier appel à timef. Pour le premier appel, la valeur renvoiée est 0.0D0.

    Exemple :
    REAL(8) ELAPSED, timef
    ELAPSED = timef()
    ...
    ELAPSED = timef()

    Cette function s'applique aux programmes séquentiels. Pour les programmes parallèles comme OpenMP, elle renvoie le temps d'une thread et non pas le temps cumulé. Pour avoir le cumul, il faut employer la function global_timef.

    • global_timef :
    La function global_timef a la même syntaxe, et la même déclaration que la function timef. Elle renvoie le temps écoulé en seconde depuis le dernier appel effectué au sein de l'équipe de threads.

    Exemple :
    REAL(8) ELAPSED, global_timef

    • mpi_wtime :
    Au sein d'un programme MPI, cette function renvoie le temps écoulé pour le processus appelant depuis un instant de référence (lui est alors constant).

    Exemple :
    REAL(8) start_time, end_time, mpi_wtime, ELAPSED
    start_time = mpi_wtime()
    ...
    end_time = mpi_wtime()
    ELAPSED = end_time - start_time

    • system_clock et cpu_time :
    ces deux functions sont intrinsèques aux Fortran90 et Fortran95.

    Retour au début de la page.


    Type de données MPI (ou SHMEM/PVM) pour les flottants

    Dans le cas de données flottantes à envoyer ou recevoir d'autre processus, il est nécessaire de fournir le type des données au moyen des constantes MPI_REAL et MPI_DOUBLE_PRECISION. Dans le cas du portage du code d'un calculateur vers un autre, on peut rencontrer les mêmes difficultés que pour les déclarations des flottants. On peut construire une parade analogue à l'aide d'une variable entière ...

    INTEGER  ::  ITYPE_REAL

    ... variable initialisée au début du programme par tous les processus, comme par exemple par :

    ! Initialize MPI
     CALL MPI_INIT(ierr)
    !
    ! Get my process rank
     CALL MPI_COMM_RANK(MPI_COMM_WORLD, myrank, ierr)
    !
    ! Get the number of processors
     CALL MPI_COMM_SIZE(MPI_COMM_WORLD, nbproc, ierr)
    !
    ! Float type
     IF ( rp == dp ) THEN
        ITYPE_REAL = MPI_DOUBLE_PRECISION
     ELSE
        ITYPE_REAL = MPI_REAL
     END IF

    Ainsi la valeur de rp ajuste le type de données

    Pour les communications, on a alors :
    CALL MPI_SEND ( x(1), 9, ITYPE_REAL, isend, istag, MPI_COMM_WORLD, ierr )

    Retour au début de la page.


    Générateur de nombres pseudo-aléatoires et MPI/OpenMP

    Il est facile d'utiliser le générateur pseudo-aléatoire intrinsèque du Fortran 90 (RANDOM_NUMER) dans un code parallèle comme MPI ou OpenMP.
    Un générateur pseudo-aléatoire est une suite de nombres définie par récurrence à partir d'un ou plusieurs premiers termes, appelés germe(s).
    Dans le cas d'une application parallèle, il est impératif que chaque processus (resp. thread) dispose de ses propres germes, différents de ceux de tous les autres processus (resp. threads).
    La subroutine RANDOM_SEED permet d'effectuer ces initialisations pour le générateur RANDOM_NUMBER.
    Voici la copie écran de la version MPI dans le cas de 4 processus :

    GM-% ./a.out -procs 4
    my_rank = 2 K = 2 AVANT iseed (1:K) = 1072693248 0
    my_rank = 0 K = 2 AVANT iseed (1:K) = 1072693248 0
    my_rank = 1 K = 2 AVANT iseed (1:K) = 1072693248 0
    my_rank = 3 K = 2 AVANT iseed (1:K) = 1072693248 0
    my_rank = 0 K = 2 APRES iseed (1:K) = 0 0
    my_rank = 1 K = 2 APRES iseed (1:K) = 214538649 0
    my_rank = 2 K = 2 APRES iseed (1:K) = 357564416 0
    my_rank = 3 K = 2 APRES iseed (1:K) = 459725676 0
    my_rank = 3 X = 0.9829E+00 0.8367E+00 0.4652E-01 0.8633E+00
    my_rank = 0 X = 0.1565E-04 0.2631E+00 0.5112E+00 0.9173E+00
    my_rank = 1 X = 0.5870E-01 0.4922E+00 0.7565E+00 0.1252E+00
    my_rank = 2 X = 0.4312E+00 0.6624E+00 0.2684E+00 0.4868E+00


    Le tableau suivant donne accès aux fichiers source des deux versions parallèles et indique la marche à suivre pour les tester :

    Type Compilation Exécution Fichier source
    MPI mpxlf90 -O2 -qfixed=132 -qextname=flush rnd_mpi.f ./a.out -procs 4 rnd_mpi.f
    OpenMP xlf90_r -qsmp=omp -O2 -qfixed=132 -qextname=flush rnd_omp.f setenv OMP_NUM_THREADS 4 ; ./a.out rnd_omp.f

    Bibliothèques scientifiques

    Les bibliothèques scientifiques sont des ensembles de sous-programmes testés, validés, optimisés. Utiliser des librairies scientifiques permet de se consacrer uniquement aux nouveaux développements et donc de ne pas devoir réinventer la roue à chaque fois. Elles ont plusieurs qualités qui les rendent très intéressantes :
    • elles sont (souvent) portables puisqu'indépendantes de l'architecture des machines;
    • elles supportent différents types de donnée : réel ou complexe, simple ou double précision;
    • elles prennent en compte différents types de stockage : matrice bande, symétrique, hermitienne.
    Tout cela les rend très intéressantes. Ainsi, il existe des librairies connues et reconnues qui sont portables, qui ont été optimisées sur différentes machines et qui sont distribuées par les constructeurs, c'est le cas notamment pour blas et lapack. Il est donc recommandé de les utiliser lorsqu'elles existent !

    D'autres bibliothèques conçues par les constructeurs présentent des avantages similaires mais ne sont pas portables puisque dépendantes de l'architecture, le cas typique est les transformées de Fourier rapides ou FFT. Elles font toutes la même chose (1D, 2D, 3D, complexe/complexe, complexe/réel ou réel/complexe, etc ...) mais les séquences d'appels changent d'un constructeur à l'autre (tableau de travail réel ou complexe, trigs à stocker de manière particulière, etc ...).
    Dans ce cas, on peut procéder de la manière suivante pour essayer de limiter les modifications lors du portage.
    On écrit une routine qui sert d'interface entre le programme et la bibliothèque. A cette routine, chacun des sous-programmes transmet les arguments suivants :

    • le tableau avec les valeurs en entrée,
    • le tableau pour les valeurs en sortie,
    • le nombre de points dans chaque direction,
    • le sens de la transformation.
    Les tableaux de travail, les coefficients de normalisation sont alors des variables locales à la routine d'interface. Pour les coefficients complexes ou trigs, on peut utiliser un tableau de stockage déclaré dans un module ou un bloc common.
    Voir les exemples tirés de la page des bibliothèques scientifiques.

    Remarque :
    ces exemples reposent sur le fait que le tableau d'entrée n'est pas le même que le tableau de sortie (cas général), même si certaines fonctions de la librairie ESSL le supportent.

    Enfin, la bibliothèque mathématique standard (fonctions trigonométriques, logarithmiques, etc ...) possède parfois une version optimisée, i.e. plus rapide avec une légère perte de précision. C'est le cas sous AIX avec la librairie MASS (Mathematical Acceleration SubSystem) qui propose deux instances : une instance pour un appel isolé à une fonction et une instance pour un appel sur un vecteur de données à traiter.
    L'usage de cette librairie n'entraine aucune modification du code source, il suffit de faire l'édition des liens en ajoutant les deux options -lmass et -lmassv.

    Retour au début de la page.


    Recopiage des données

    L'algorithme utilisé peut nécessiter la présence simultanée des valeurs correspondant à deux itérations successives.
    Dans l'écriture de l'algorithme qui suit, chaque itération demande la recopie du tableau des nouvelles valeurs obtenues dans le tableau des anciennes, (on suppose que les initialisations sont faites).

    Exemple :

    Version initiale ...

    REAL(rp),  DIMENSION(n:n)  ::  F
    REAL(rp),  DIMENSION(n:n)  ::  U_new, U_old
    REAL(rp)   ::  h
    INTEGER    ::  i, j

    DO WHILE ( MAXVAL( ABS(U_new - Uold) ) > erreur ) U_old = U_new

    DO j = 1, n

    DO i = 1, n
    U_new(i,j) = 0.25_rp *(
      U_old(i-1,j)   + U_old(i+1,j)
    + U_old(i  ,j-1) + U_old(i  ,j+1) )
    - h * h * F(i,j)
    END DO
    END DO
    END DO

    On peut éviter cette recopie en utilisant un couple d'entiers qui joueront en quelque sorte le role de "pointeurs".
    Version proposée ...

    REAL(rp),  DIMENSION(n:n)     :: F
    REAL(rp),  DIMENSION(n:n,0:1) :: U
    REAL(rp)   ::  h
    INTEGER    ::  i, j
    INTEGER    ::  iold = 0
    INTEGER    ::  inew = 1
    INTEGER    ::  itemp

    DO WHILE ( MAXVAL( ABS(U(:,:,inew) - U(:,:,iold) ) ) > erreur )

    itemp = iold
    iold  = inew
    inew  = itemp
    DO j = 1, n
    DO i = 1, n
    U(i,j,inew) = 0.25_rp *(
      U(i-1,j  ,iold) + U(i+1,j  ,iold)
    + U(i  ,j-1,iold) + U(i  ,j+1,iold) )
    - h * h * F(i,j)
    END DO
    END DO
    END DO

    On remarque que le volume en mémoire reste le même.

    On peut aller encore un peu plus loin : dans cette boucle le compilateur n'arrive pas forcément à comprendre que iold et inew sont différents à chaque itération et donc qu'il n'y aura jamais de recouvrement entre ce qui est lu et ce qui est écrit pour un couple (i,j) donné. Ainsi il risque de mettre des "verrous", des recopies en mémoire (cohérence globale) des registres, etc ...

    La version suivante, en allongeant un peu le code, permet de gagner encore en performance (10%), ce qui peut être très intéressant si cette boucle est au coeur du code :

    DO WHILE ( MAXVAL( ABS(U(:,:,inew) - U(:,:,iold) ) ) > erreur )

    itemp = iold
    iold  = inew
    inew  = itemp
    IF ( inew == 0 ) THEN
    DO j = 1, n
    DO i = 1, n
    U(i,j,0) = 0.25_rp *(
      U(i-1,j  ,1) + U(i+1,j  ,1)
    + U(i  ,j-1,1) + U(i  ,j+1,1) )
    - h * h * F(i,j)
    END DO
    END DO
    ELSE
    DO j = 1, n
    DO i = 1, n
    U(i,j,1) = 0.25_rp *(
      U(i-1,j  ,0) + U(i+1,j  ,0)
    + U(i  ,j-1,0) + U(i  ,j+1,0) )
    - h * h * F(i,j)
    END DO
    END DO
    END IF
    END DO

    Remarque :
    il est capital que le test IF ... THEN ... ELSE reste en dehors des deux boucles imbriquées sur i et j.


    Calcul de puissances de nombres flottants

    Le calcul des puissances peut révéler quelques subtilités ...
    D'un point de vue mathématique, on a x**y = EXP( y * LN( x ) ). Le calcul d'une exponentielle et d'un logarithme est relativement élevé en terme de coût CPU. Dans le cas d'un exposant entier, il s'agit d'une élévation à la puissance et le calcul ne doit pas être fait avec ces deux fonctions mathématiques; il faut donc faire attention à ce que l'exposant soit bien vu comme une entité de type entier. De plus en ne faisant pas attention on peut facilement perdre du temps CPU .... et même de la précision dans le cas général.

    Exemple :

    REAL(rp),  DIMENSION(n)  ::  a, b

    DO i = 1, n

    a(i) = b(i) ** 3.0_rp   <-- mauvais  car l'exposant est écrit comme un flottant
    a(i) = b(i) ** 3        <-- meilleur car l'opération est faite en deux multiplications
    END DO

    De même lorsque l'on calcule les puissances successives, ....

    REAL(rp),  DIMENSION(n)  ::  a, b
    REAL(rp)   ::  x

    DO i = 1, n

    a(i) = x**i           <-- nécessite n*(n-1)/2 multiplications
    END DO
     

    b(1) = x
    DO i = 1, n-1

    b(i+1) = b(i) * x     <-- nécessite n-1 multiplications
    END DO

    Remarque :
    Toutefois d'autres considérations, liées à l'architecture de la machine, peuvent amener à privilégier un algorithme un peu plus cher en terme de nombre d'opérations, s'il en découle un gain significatif en terme de performances (vectorisation, parallélisation). Des comparaisons de performances permettent généralement de déterminer l'algorithme le mieux adapté.

    Le phénomène s'aggrave lorsque l'on travaille avec des complexes puisque un complexe est formé de deux réels ...
    De même, les puissances successives du i complexe (i^2 = -1) peuvent ne pas être calculées ... mais simplement déduites ... gains significatifs en temps calcul et en précision des résultats.


    Evaluation polynômiale

    Evaluer un polynôme de degré n peut se faire en O(n) opérations.
    Cela a aussi pour effet de limiter les erreurs d'arrondi en cumulant les contributions de chaque terme du polynôme. Pour le faire on écrit le polynôme suivant sous la forme suivante :
    2x3 + 7x2 + 5x + 3 = ( (2x + 7)x + 5)x + 3

    De manière générale, l'évaluation du polynôme se fait à l'aide de la boucle suivante :
    INTEGER :: n, i
    REAL(rp), DIMENSION(0:n) :: P
    REAL(rp) :: s
    (valeur du polynôme en x)
    s = P(n)
    DO i = n-1, 0, -1
        s = s * x + P(i)
    END DO


    Arithmétique entière

    Comme les processeurs RISC sont surtout orientés pour le calcul flottant, certaines opérations effectuées sur les nombres entiers ne sont pas optimisées dans les compilateurs : la division est très chère à effectuer.
    Dans le cas de divisions par puissance de 2, on a un moyen efficace de les effectuer : la fonction ishft.

    Au lieu d'écrire ... on peut mettre
    k = i*(i-1)/2 k = ISHFT( i*(i-1), -1 )
    l = j*m/8 l = ISHFT( j*m, -3 )

    Remarque :
    L'expression i*(i-1)/2 est dangereuse car elle ne donne pas forcément le même résultat selon l'ordre dans lequel les opérations sont effectuées : on sait que i ou i-1 est pair donc le produit est pair ; la division entière par 2 se passe bien.
    Dans le cas où i est pair, alors (i-1) est impair et si la division a lieu avant la multiplication, i.e. le calcul de (i-1)/2 avant celui de i*(i-1), le résultat est faux. Cela peut survenir lorsque le niveau d'optimisation est assez haut. Il ne faut pas alors se limiter à un niveau inférieur d'optimisation mais plutôt éliminer ces instructions potentiellement dangereuses.

    Le premier argument de la fonction ishft est le nombre à diviser, le second est l'exposant de la puissance de 2.
    On peut noter que cela fonctionne aussi très bien pour la multiplication d'un entier par une puissance de 2 :

    ir*4 = ISHFT ( ir, 2 )


    Calcul des constantes flottantes

    Le calcul des constantes scalaires peut entrainer des pertes de précision significatives. Ainsi il est dommage de travailler avec des variables en double précision si les constantes scalaires sont évaluées en simple précision. On perd en précision et en temps calcul puisque les opérations en double précision coûtent au moins aussi cher que celles en simple précision.

    Exemples :
    INTEGER,  PARAMETER  ::  rp = kind(1.0D0)
    REAL(rp)  ::  PI_S,  a_s,  b_s
    REAL(rp)  ::  PI_D,  a_d,  b_d
     

    PI_S = ACOS( -1.0    )       <-- PI_S = 3.14159274101257324
    PI_D = ACOS( -1.0_rp )       <-- PI_D = 3.14159265358979312

    a_s = 0.5 * SQRT( 2.0 )      <-- a_s  = 0.707106769084930420
    a_d = 0.5 * SQRT( 2.0_rp )   <-- a_d  = 0.707106781186547573

    b_s = 2.0    / 3.0           <-- b_s  = 0.666666686534881592
    b_d = 2.0_rp / 3.0_rp        <-- b_d  = 0.666666666666666630
     

    Cela se complique lorsque certaines options de compilation donnent les mêmes résultats dans les deux cas alors que d'autres distinguent les calculs en simple précision des calculs en double. En effet, ce genre de constantes scalaires est évalué lors de la compilation du programme.
    Le tableau ci-dessous reprend quelques exemples :

    Séquence de compilation Résultats identiques ?
    xlf90_r -qfree=f90 test.f VRAI
    xlf90_r -qfree=f90 -O2 test.f VRAI
    xlf90_r -qfree=f90 -O3 test.f VRAI
    xlf90_r -qfree=f90 -qhot -O3 test.f FAUX uniquement pour 1/2 * SQRT(1/2)
    xlf90_r -qfree=f90 -qhot -O3 -qstrict test.f FAUX uniquement pour 1/2 * SQRT(1/2)
    xlf90_r -qfree=f90 -qarch=pwr4 test.f FAUX
    xlf90_r -qfree=f90 -qarch=pwr4 -qautodbl=dbl4 test.f VRAI

    Conclusion :
    toute seule, sans autre optimisation ou option, l'option -qarch arrive à modifier les résultats !
    L'option -qhot ne calcule pas les constantes scalaires de la même manière lorsque la fonction SQRT intervient.
    L'option -qautodbl=dbl4 en convertissant toutes les constantes en double précision avant évaluation corrige le problème.
    De manière générale, il est conseillé de s'assurer de la précision des calculs dès l'écriture du programme, en imposant explicitement la précision souhaitée, comme dans l'exemple Fortran90 ci-dessus.


    Tests arithmétiques flottants selon la précision machine


    Bon :     IF ( ABS(reel1 - reel2) .LT. machep) THEN ...
    Mauvais : IF ( reel1 .EQ. reel2 ) THEN ...
    avec machep une valeur de référence pour désigner une quantité négligeable :
        machep = 1.0_rp
    100 IF (machep + 1.0_rp .GT. 1.0_rp ) THEN
           machep = 0.5_rp * machep
           GO TO 100
        END IF
        machep = 10.0_rp * machep
    machep est proche de 1E-6 en simple précision et 1E-14 en double précision.
    On peut aussi utiliser les fonctions Fortran90 comme EPSILON.

    Sauvegarde et reprise

    La sauvegarde consiste à générer des fichiers de résultats pour une étude postérieure à l'exécution. On choisira alors une sortie formatée avec des descripteurs fournissant une précision adéquate : 4 ou 5 décimales sont suffisantes pour une représentation graphique classique.
    Une reprise consiste à générer des fichiers non formatés contenant toutes les informations (variables, paramètres, etc ..) nécessaires à la poursuite d'un calcul sur la même architecture comme s'il n'y avait pas eu d'interruption. Il faut donc faire en sorte que toute l'information présente en mémoire se retrouve dans le fichier sans perte de précision. Prévoir des reprises fréquentes est impératif que ce soit pour pallier à une interruption d'exécution (dépassement du temps cpu alloué dans la requête Loadleveler, par exemple) ou bien lorsque l'on souhaite poursuivre une simulation (convergence insuffisante, par exemple), sans pour autant devoir la relancer depuis l'instant initial.
    D'autre part, pour les exploitations graphiques, il existe des formats spécifiques supportés par de nombreux logiciels :
    1. HDF est un format de données développé par le NCSA (National Center for Supercomputing Applications). La liste des logiciels supportant ce format comprend entre autres MATLAB, PV-Wave et Tecplot.
    2. NetCDF est un format de données développé par UCAR (University Corporation for Atmospheric Research). La liste des logiciels supportant ce format comprend entre autres AVS, MATLAB et PV-Wave.

    Powered by Plone CMS, the Open Source Content Management System

    This site conforms to the following standards: