Previous Contents Next
Chapter 15 Étude de cas sur un processeur vectoriel
Le but de cette étude de cas est de montrer qu'un code efficace peut être obtenu en utilisant le détecteur de scans. Un autre intérêt est de montrer quelles sont les difficultés que pose la génération manuelle de code pour des scans. Ces enseignements sont indispensables pour la conception d'un système de génération automatique de code. La machine utilisée pour les tests est un Cray Y-MP d'entrée de gamme (horloge de 30 nano-secondes) avec 4 processeurs. Pour calculer les scans et les réductions en parallèle nous utilisons à la fois des primitives de la bibliothèque du Cray et des procédures écrites pour l'occasion. La génération du code pour les deux exemples est simplifiée du fait que toutes les données des réductions incluses dans les programmes sont prêtes dès l'instant 0. En conséquence du code asap doit être généré pour les réductions et du code naïf pour les scans.

15.1 Procédures de calcul de scans
Le premier problème rencontré pour la génération de code pour les scans et les réductions sur un Cray est l'absence de certaines primitives de calcul de récurrences optimisées. La bibliothèque d'algèbre linéaire du Cray (BLAS1) contient quelques primitives de réduction optimisées pour les fonctions somme, maximum et minimum et produit scalaire. Évidemment, la primitive pour calculer efficacement la fonction search décrite à la section 10.3 n'existe pas. Comme elle est utilisée dans un de nos deux exemples, nous l'avons implanté. Cela se fait facilement en utilisant la réduction-maximum et en utilisant le tableau de booléens comme un tableau d'entiers contenant uniquement des 0 et des 1. Le tableau est plus noir pour les scans. Les seuls scans correctement optimisés que nous avons trouvé sont ceux relatifs à l'addition et la multiplication. Or les exemples nécessitent des scans pour la multiplication complexe, pour le maximum et pour le minimum. Nous avons donc été conduit à implanter nos propres scans. La méthode utilisée est la méthode dite du partitionnement.
Par exemple, si o est la taille des registres vectoriels de la machine, la procédure pour calculer les sommes partielles d'un tableau s entre les bornes start et end est :
p=(end-start+1)/o
DO i=1,p-1
  DO j=start,end,p
    s(j+i)=s(j+i)+s(j+i-1)
  END DO
END DO
DO i=start+p,end,p
  DO j=p-1,0,-1
    s(i+j)=s(i+j)+s(i-1)
  END DO
END DO
La boucle de pas -1 dans le second nid permet de ne pas introduire de bulle dans le pipeline. Ce code est du, en grande partie, au professeur C. Timsit du laboratoire PRiSM.
Les chercheurs de l'université de Carnegie Mellon ont mis au point une technique plus performante pour implanter les scans [13]. Cette technique du ``loop raking'' réduit le trafic mémoire. L'un des auteurs Marco Zagha reconnaît que pour le Cray Y-MP, le ``loop raking'' n'apporte pas d'amélioration notable. Effectivement, leur implantation en Fortran du calcul des sommes partielles n'est pas plus rapide que la notre. Par contre leur implantation en C et assembleur présente une accélération de 2 par rapport à la version Fortran.
Les temps d'exécution pour un test de calcul de sommes partielles que nous avons effectué sur le Cray Y-MP du Centre de Calcul de l'université Pierre et Marie Curie sont résumés dans le tableau ci-dessous.
Méthode utilisée Temps d'exécution
Séquentielle 0.35s
Partitionnement 0.17s
Bibliothèque BLAS1 0.11s
``loop raking'' en C 0.09s
Les résultats peuvent être considérés comme asymptotiques puisque le vecteur traité comporte un million de composantes.
Au vu de ces résultats nous avons implantés les scans par la méthode du partitionnement en gardant en mémoire que les temps d'exécutions pouvaient être divisés par deux en utilisant le langage C et l'assembleur du Cray.

15.2 Premier exemple : Évaluation d'un polynôme complexe
L'évaluation d'un polynôme complexe se fait en deux étapes. La première étape calcule les puissances du point complexe (xRe,xIm) en lequel est évalué le polynôme :
yRe(1)=1.0
yIm(1)=0.0
DO i=2,n
  tRe=yRe(i-1)             (v1)
  tIm=yIm(i-1)             (v2)
  yRe(i)=tRe*xRe-tIm*xIm   (v3)
  yIm(i)=tRe*xIm+tIm*xRe   (v4)
END DO
La seconde étape consiste à effectuer un produit scalaire entre ces puissances et les coefficients du polynôme.
tRe=aRe(1)                             (v5)
tIm=aIm(1)                             (v6)
DO i=2,n
  tRe=tRe+yRe(i)*aRe(i)-yIm(i)*aIm(i)  (v7)
  tIm=tIm+yRe(i)*aIm(i)+yIm(i)*aRe(i)  (v8)
END DO
La seconde section du code produit un système d'équations normalisé. Ce n'est pas le cas de la première section. Malheureusement, il s'agit d'un système irréductible. Il est seulement possible, à l'aide de substitutions de réduire le nombre d'équations à 2 :
v3 : { i | 2 <= i <= n } of real ;
v4 : { i | 2 <= i <= n } of real ;
v3[i] = case
         { i | i=2 } : 1.0 * xRe[i] - 0.0 * xIm[i] ;
         { i | i>2 } : v3[i-1] * xRe[i] - v4[i-1] * xIm[i] ;
        esac ;
v4[i] = case
         { i | i=2 } : 1.0 * xIm[i] - 0.0 * xRe[i] ;
         { i | i>2 } : v3[i-1] * xIm[i] + v4[i-1] * xRe[i] ;
        esac ;
Le graphe de ce système est :
tous ses cycles ne passent pas par le même sommet. Par contre les équations v3 et v4 possèdent le même domaine de définition, il est facile de les fusionner. Le système devient :
v3 : { i | 2 <= i <= n } of real x real ;
v3[i] = case
         { i | i=2 } :
            ( 1.0*xRe[i]-0.0*xIm[i] ) x ( 1.0*xIm[i]-0.0*xRe[i] ) ;
         { i | i>2 } :
            ( proj(v3,1)[i-1]*xRe[i] - proj(v3,2)[i-1]*xIm[i] ) x
            ( proj(v3,1)[i-1]*xIm[i] + proj(v3,1)[i-1]*xRe[i] ) ;
        esac ;
Rappelons que nous notons x l'opérateur qui forme des couples à partir de scalaires. De plus proj représente l'opérateur de projection qui associe à un tuple et à une coordonnée la composante du tuple correspondant à la coordonnée. Le système se prête alors complètement à la détection des scans. Le système après la détection est :
v3 : { i | 2 <= i <= n } of real x real ;
v7 : { i | 2 <= i <= n } of real ;
v8 : { i | 2 <= i <= n } of real ;
v3[i] = case
         { i | i=2 } :
            ( 1.0*xRe[i]-0.0*xIm[i] ) x ( 1.0*xIm[i]-0.0*xRe[i] ) ;
         { i | i>2 } :
            Scan( { i' | 2<=i'<=n }, ([1]), *, xRe[i'] x xIm[i'], v3[i'] ) ;
        esac ;
v7[i] = case
         { i | i=2 } :
            aRe[1]+aRe[i]*proj(v3,1)[i]-aIm[i]*proj(v3,2)[i] ;
         { i | i>2 } :
            Scan( { i' | 2<=i'<=n }, ([1]), +,
                  aRe[i']*proj(v3,1)[i']-aIm[i']*proj(v3,2)[i'], v7[i'] ) ;
        esac ;
v8[i] = case
         { i | i=2 } :
            aRe[1]+aIm[i]*proj(v3,1)[i]+aRe[i]*proj(v3,2)[i] ;
         { i | i>2 } :
            Scan( { i' | 2<=i'<=n }, ( [1] ), +,
                  aIm[i']*proj(v3,1)[i']+aRe[i']*proj(v3,2)[i'], v8[i'] ) ;
        esac ;
Dans ce système, les complexes sont considérés comme des couples de réels (e.g. le couple xRe[i'] x xIm[i'] représente le complexe xRe[i']+xIm[i'].i). Les bases de temps du système sont facile à trouver :
q(v3.0)=1 q(v2.1)=2
q(v7.0)=2 q(v3.1)=3
q(v8.0)=2 q(v3.1)=3
 .
La génération de code décrite en 12 conduit au code suivant :
DO t=1,3
  IF (t.EQ.1) THEN
    v3(2)=COMPLX(xRe,xIm)
  END IF
  IF (t.EQ.2) THEN
    t1(2:n)=COMPLX(xRe,xIm)
    v3(2:n)=PRODUCT_PREFIX(temp1(2:n))
    v7(2)=aRe(1)+aRe(2)*REAL(v3(2))-aIm(1)*AIMAG(v3(2))
    v8(2)=aIm(1)+aIm(2)*REAL(v3(2))+aRe(1)*AIMAG(v3(2))
  END IF
  IF (t.EQ.3) THEN
    t2(2)=v7(2)
    t2(3:n)=REAL(v3(3:n))*aRe(3:n)-AIMAG(v3(3:n))*aIm(3:n)
    v7(2:n)=SUM_PREFIX(t2(2:n))
    t2(2)=ins6(2)
    t2(3:n)=REAL(v3(3:n))*aIm(3:n)+AIMAG(v3(3:n))*aRe(3:n)
    v8(2:n)=SUM_PREFIX(temp2(2:n))
  END IF
END DO
Les calculs de réductions et de scans sont exprimés en utilisant une version simplifiée des primitives de HPF. Dans la suite du programme seuls v7(n) et v8(n) sont utilisés. En conséquence des SUM_SCATTER doivent être utilisé au lieu des SUM_PREFIX (il s'agit d'un code asap). Les résultats de nos tests sont subsumés dans le tableau des accélérations ci-dessous :
Version \ degré 100 1000 104 105
Vectoriel (Cray) 2.2 2.6 2.7 2.8
Vectoriel (détecteur) 0.7 2.8 3.6 3.8
Vectoriel (assembleur) 0.9 3.0 4.6 5.0
Vectoriel et parallèle - 2.0 8.1 10.2
Les accélérations sont toutes calculées par rapport au programme initial compilé en forçant le compilateur du Cray à générer du code séquentiel. La première ligne montre que le compilateur du Cray 1 est déjà capable de détection de réductions (a priori par une reconnaissance de forme assez brutale). En fait les deux dernières sommes sont reconnues. Notre code vectoriel se comporte mal avec des petites valeurs du degré du polynôme, mais il est meilleur asymptotiquement car le scan relatif à la multiplication complexe a été détecté. Le code vectoriel dit assembleur est calculé en analysant les temps passés dans chaque procédure et en divisant par deux ceux concernant nos procédures de calcul de scan. Nous avons vu que c'est l'accélération du au passage en code assembleur. Enfin la dernière ligne montre les temps obtenus en utilisant des procédures de scan à la fois vectorisées et parallélisées sur les 4 processeurs du Cray. La parallélisation ne concerne pas que ces procédures ce qui explique les bons résultats (en particulier, les boucles sur les temporaires sont elles aussi parallélisées).

15.3 Second exemple : Un calcul de points selle
Le calcul d'un point selle permet de rechercher les cols des montagnes à partir d'une carte des élévations (l'exemple est tiré d'un livre de référence pour Fortran 77 [54]). Il s'agit de trouver les points d'une matrice de nombres qui sont à la fois le maximum de leur colonne et le minimum de leur ligne (ou inversement).
Par exemple dans la matrice ci-dessous :
le point encerclé est un point selle.
L'implantation de la recherche des points selle consiste à calculer : Une manière de procéder est donné dans l'extrait de programme ci-dessous.
DO i=1,n
  DO j=1,n
    colmax=field(n+1,j)              (v1)
    ...
    IF (field(i,j).GT.colmax) THEN
      field(n+1,j)=field(i,j)        (v2)
      field(n+3,j)=i                 (v3)
    END IF
    ...
  END DO
END DO
Seul le code calculant les numéros de lignes des maximums des colonnes est explicité. Les autres calculs sont semblables. Les extremums et les indices de ces extremums sont stockés dans les quatre dernières lignes et dans les quatre dernières colonnes du tableau. Après une détection des récurrences nous obtenons le système suivant.
field : { i,j | 1<=i<=n, 1<=j<=n } of real ;
v2 : { i,j | 1<=i<=n, 1<=j<=n } of real ;
v3 : { i,j | 1<=i<=n, 1<=j<=n } of real ;
v2[i,j] = case
           { i,j | i=1 } : if ( field[1,j] > field[n+1,j] )
                           then field[1,j] else field[n+1,j] ;
           { i,j | i>1 } : Scan( {i',j' | 1<=i'<=n, 1<=j'<=n}, ([1 0]),
                                 max, field[i',j'], v2[i',j'] ) ;
         esac ;
v3[i,j] = case
           { i,j | i=1 } : if ( field[1,j] > field[n+1,j] )
                           then 1 else field[n+3,j] ;
           { i,j | i>1 } : proj(
                             Scan( {i',j' | 1<=i'<=n, 1<=j'<=n}, ([1 0]),
                                   search,
                                   ( field[i',j'] > v2[i'-1,j'] ) x i',
                                   ( field[i',j'] > field[n+3,j'] ) x 1 ),
                             2 ) ;
         esac ;
Le if-then-else qui apparaît dans l'expression des équations représente une expression conditionnelle de même sémantique que le b?t:f du langage C. Notons que l'opérateur Scan de v2 décrit des scans car toutes ses valeurs sont utilisées par v3 alors que l'opérateur Scan de v3 décrit des réductions car seule une valeur par ligne d'accumulation est utilisée. Les bases de temps de ce système sont triviales :
q(v2.0)=1 q(v2.1)=2
q(v3.0)=1 q(v3.1)=3
 .
En utilisant les outils classiques de génération de code, il est possible de construire un programme parallèle dont une portion est donnée ci-dessous.
DO j=1,3
  ...
  IF (t.EQ.1) THEN
    v2(1,1:n)=MAX(field(1,1:n),field(n+1,1:n))
    DO ALL j=1,n
      IF ((field(1,j).GT.field(n+1,j)) THEN
        v3(1,j)=1
      ELSE
        v3(1,j)=field(n+3,j)
      END IF
    END DO
  END IF
  IF (t.EQ.2) THEN
    DO ALL j=1,n
      t1(1)=v2(1,j)
      t1(2:n)=field(2:n,j)
      v2(1:n,j)=MAXVAL_PREFIX(t1(1:n))
    END DO
  END IF
  IF (t.EQ.3) THEN
    DO ALL j=1,n
      t2(1)=v2(1,j)
      t2(2:n)=field(2:n,j).GT.v2(1:n-1,j)
      t3(1)=1
      DO ALL index=2,n
        t3(index)=index
      END DO
      v3(1:n,j)=SEL_PREFIX(t2(1:n),t3(1:n))
    END DO
  END IF
  ...
END DO
Comme les seuls éléments du tableau v3 qui sont utilisés dans la suite du programme sont les v3(n,j), la dernière instruction d'affectation peut être simplifiée :
v3(n,j)=SEL_SCATTER(t2(1:n),t3(1:n))
Les résultats des tests sont donnés dans le tableau suivant :
Version \ n 1000 4000
Vectoriel (détecteur) 1.5 1.8
Vectoriel (assembleur) 1.9 2.2
Vectoriel et parallèle 1.3 1.8
Le compilateur de Cray n'a pas réussi à détecter de réduction dans le programme initial (soit à cause des IF, soit à cause de l'utilisation des dernières lignes et colonnes du tableau des élévations pour stocker les résultats des calculs). En théorie une accélération de 2 peut être obtenue avec notre code vectoriel. C'est assez encourageant dans la mesure ou un calcul de scan en parallèle nécessite au moins 2 fois plus d'opérations qu'un scan séquentiel. Sachant qu'un processeur vectoriel d'un Cray Y-MP peut accélérer jusqu'à 10 fois un programme séquentiel, l'accélération maximale est de 5. L'accélération obtenue n'est donc pas ridicule, sachant que des manipulations sur des temporaires sont rajoutées. Paralléliser la procédure de calcul des scans n'est pas bénéfique, c'est normal car les scans sont assez courts (1000 à 4000 données). Si seuls les maximums et minimums de chaque ligne et de chaque colonne sont calculés (leurs positions étant inutiles), plus aucun scan n'est nécessaire. Les performances du programme vectorisé s'envolent :
Version \ n 1000 4000
Vectoriel (détecteur) 6.8 7.9


1
Nous avons utilisé le Cray Fortran version 5.0.

Previous Contents Next