• Aucun résultat trouvé

Perspectives

Dans le document Vers des noyaux de calcul intensif pérennes (Page 155-162)

Naturellement, la première suite qui peut être donnée à ces travaux consiste à intégrer les résultats précédents dans une version stable et industrialisable de Legolas++. Cela pose la ques-tion technique du choix du langage. Les différents outils conçus dans le cadre de cette thèse ont été implémentés en C++. Une autre solution aurait été de définir un langage ad hoc et de con-cevoir son compilateur. Cette solution aurait permis de simplifier la syntaxe, en particulier pour MTPS, mais cette stratégie implique des investissements importants dans l’environnement de développement. Cependant, la complexité atteinte dans les mécanismes de métaprogrammation peuvent laisser penser que nous sommes proches des limites de l’approche dans la mesure où il

est aujourd’hui relativement compliqué de déboguer un métaprogramme C++ en raison de l’ab-sence d’outils dédiés. Aujourd’hui, seules les vérifications lexicales et syntaxiques sont effectuées par le compilateur et il est par exemple impossible de suivre pas à pas les différentes étapes de la génération du code optimisé, ni d’explorer l’ensemble des éléments constituants une classe donnée. Une approche basée sur un langage dédié disposant de son compilateur aurait en outre l’avantage de permettre la génération de code OpenCL plutôt que CUDA, ce qui permettrait alors de cibler tous les accélérateurs matériels disponibles aujourd’hui (et vraisemblablement demain).

Au cours de cette thèse, nous avons montré l’importance du format de stockage des données dans l’obtention de performances optimales. Nous avons en outre introduit un mécanisme de transformation permettant d’adapter automatiquement le format de stockage des structures de données bidimensionnelles. Il nous semble important de généraliser cette réflexion. En effet, dans notre approche, le fait de nous ramener à une structure de donnée bidimensionnelle conduit à laisser à l’utilisateur le soin d’exprimer la localité des données : les données d’une même ligne sont supposées être plus proches que les données d’une même colonne. Implicitement, l’utilisateur établit alors un lien entre l’algorithme qu’il choisit d’utiliser et la structure de données qu’il exprime. La question de savoir s’il est possible d’exprimer cette localité de données sans imposer une structure de données reste ouverte. Cela offrirait plus de libertés dans l’adaptation du format de stockage des données. Suite à cette question, il conviendrait alors d’étudier comment calculer la localité des données à partir de l’algorithme défini par l’utilisateur.

Les langages de programmation impératifs obligent l’utilisateur à définir le format de stock-age de ses données. Cela lui permet de définir des structures adaptées à son problème et à son environnement matériel. Au contraire, les langages déclaratifs ne permettent généralement de définir ni le format de stockage des données, ni les relations de proximité entre ces données. Un plus juste milieu consisterait à fournir à l’environnement d’exécution ou de compilation les informations lui permettant de générer les structures de données optimales. La programmation orientée objet est un pas dans cette voie, mais ne permet d’exprimer qu’une localité des données sémantiques. En effet, les membres données d’une classes sont proches en mémoire car ils con-tribuent à un même but : définir l’état d’un objet. Cependant, pour les meilleures performances possibles, cette analyse devrait être faite en fonction de l’algorithme à appliquer.

En attendant de trouver une réponse générale, les langages dédiés (DSL ou DSEL) peuvent apporter une réponse partielle pour des domaines précis. En effet, leur interface peut être déclar-ative et, s’agissant d’un domaine applicatif restreint, il est possible de déterminer les règles de génération des structures de données optimisées à partir des connaissances a priori issues du domaine. La démarche qui nous a conduit à concevoir une version multicible de Legolas++ peut ainsi être appliquée pour d’autres domaines que l’algèbre linéaire.

Du polymorphisme dynamique au

polymorphisme statique

La Programmation Orientée Objet (POO) est une approche permettant d’augmenter l’ex-pressivité d’une bibliothèque. En effet, la possibilité de définir des objets possédant des fonctions membres et de passer ces objets en argument d’autres fonctions permet in fine d’écrire des fonc-tions qui prennent comme argument des foncfonc-tions. Un objet est une structure de données valuées et cachées qui répond à un ensemble de messages correspondant à des fonctions « membres ». Cette structure de données définit son état tandis que l’ensemble des messages qu’il accepte décrit son comportement.

Des objets de différentes classes peuvent définir la même interface, c’est-à-dire accepter les mêmes messages. L’implémentation des fonctions permettant le traitement de ces messages sera cependant généralement différente d’une classe d’objet à l’autre. La POO permet l’encapsulation des fonctions et des données : en fonction de leur état interne, deux objets peuvent se comporter différemment face aux mêmes sollicitations. Du point de vue de l’utilisateur d’une Bibliothèque Orientée Objet (BOO), un même extrait de code pourra donc générer des exécutions différentes selon le type et l’état interne des différents objets manipulés.

Nous allons dans cette annexe étudier la conception d’une BOO afin de comprendre comment cette approche permet d’améliorer l’expressivité d’une bibliothèque. Nous allons nous intéresser au cas présenté dans la section 1.2.2. Nous présenterons dans un premier temps la conception et l’implémentation de cette bibliothèque en C++ en utilisant les techniques de programma-tion habituelles de ce langage. Dans un second temps, nous présenterons une implémentaprogramma-tion alternative permettant l’obtention de meilleures performances mais dont l’utilisation est plus contrainte.

A.1 Programmation orientée objet

Une expression vectorielle est soit un vecteur, soit une combinaison linéaire d’expressions vectorielles. Les différentes expressions vectorielles possibles ne possèdent donc pas la même structure de données interne. Par exemple, certaines expressions comportent un seul vecteur tandis que d’autres en comportent trois. Cependant, elles possèdent toutes (le simple vecteur inclus) un comportement commun : elles peuvent communiquer une valeur scalaire pour chaque indice compris entre zéro et leur taille. Il est possible de formaliser cette propriété dans une classe abstraite virtuelle VectorExpression dont hériteront toutes les expressions vectorielles :

1 c l a s s V e c t o r E x p r e s s i o n {

2 v i r t u a l int s i z e () c o n s t =0;

3 v i r t u a l f l o a t get (c o n s t int i ) c o n s t =0; 4 };

En C++, une classe abstraite contient au moins une fonction purement virtuelle. Afin de définir une fonction comme purement virtuelle, il suffit de la déclarer virtuelle (avec le mot-clé virtual) et de la définir comme étant égale à zéro (=0). La ligne 2 déclare que les classes héritant de VectorExpression doivent définir une fonction virtuelle appelée size qui ne prend aucun ar-gument et qui renvoie un entier. La ligne 3 déclare la fonction get qui prend en argument un entier i et renvoie un nombre flottant.

D’autres informations sémantiques non explicitement formulées s’ajoutent à ces définitions : – l’entier retourné par la fonction size représente la taille des vecteurs de l’expression, – le nombre flottant retourné par la fonction get est égal au ième élément de l’expression. Avec la définition de cette classe, nous disposons de suffisamment d’informations pour pouvoir mettre au point une fonction qui calcule la norme des expressions vectorielles :

f l o a t n o r m (c o n s t V e c t o r E x p r e s s i o n & ve ) { f l o a t out = 0; for (int i =0; i < ve . s i z e () ; ++ i ) { tmp = ve . get ( i ) ; out += tmp * tmp ; } r e t u r n s q r t f ( out ) ; }

Naturellement, nous ne pouvons pas utiliser directement cette fonction. En effet, la classe VectorExpression est abstraite et ne peut donc pas être instanciée. Pour pouvoir utiliser cette fonction, il nous faut donc définir des classes héritant de VectorExpression. La première classe que nous allons implémenter est la classe Vector qui représente un vecteur :

1 c l a s s V e c t o r : p u b l i c V e c t o r E x p r e s s i o n { 2 p u b l i c: 3 V e c t o r (c o n s t int s i z e ) : s i z e _ ( s i z e ) , d a t a _ (new f l o a t[ s i z e _ ]) {} 4 ~ V e c t o r () {d e l e t e[] d a t a _ ;} 5 ... 6 v i r t u a l int s i z e () c o n s t { r e t u r n s i z e _ ; }; 7 v i r t u a l f l o a t get (int i ) c o n s t { r e t u r n d a t a _ [ i ]; }; 8 f l o a t & set (int i ) { r e t u r n d a t a _ [ i ]; };

9 ...

10 p r i v a t e:

11 c o n s t int s i z e _ ; 12 f l o a t * d a t a _ ; 13 };

La ligne 1 déclare que la classe Vector hérite de la classe VectorExpression. Afin de pouvoir être instanciée, cette classe ne doit plus contenir de fonction purement virtuelle. Les fonctions size et get sont donc respectivement surchargées aux lignes6 et7.

Contrairement au paradigme fonctionnel, la programmation orientée objet autorise la mod-ification des données. La fonction set, définie à la ligne 8, permet donc de modifier les données du vecteur.

Comme la classe Vector hérite de la classe VectorExpression, nous pouvons maintenant calculer la norme d’un vecteur. Afin de prendre en charge les expressions vectorielles plus com-plexes, nous devons définir les autres classes correspondant aux expressions vectorielles. Une expression vectorielle est une combinaison linéaire d’expressions vectorielles. Deux types d’opéra-tions doivent donc être exprimables : la somme de deux expressions vectorielles d’une part et le

produit d’une expression vectorielle avec un scalaire d’autre part. Nous allons donc définir deux classes AddVectorExpr et ScalVectorExpr correspondant respectivement à ces deux opérations.

Naturellement, AddVectorExpr hérite de la classe abstraite VectorExpression :

1 c l a s s A d d V e c t o r E x p r : p u b l i c V e c t o r E x p r e s s i o n { 2 p u b l i c: 3 A d d V e c t o r E x p r (c o n s t V e c t o r E x p r e s s i o n & l , c o n s t V e c t o r E x p r e s s i o n & r ) 4 : l e f t _ ( l ) , r i g h t _ ( r ) { a s s e r t ( l e f t _ . s i z e () == r i g h t _ . s i z e () ) ; } 5 v i r t u a l int s i z e () c o n s t { r e t u r n l e f t _ . s i z e () ; }; 6 v i r t u a l f l o a t get (c o n s t int i ) c o n s t { 7 r e t u r n l e f t _ . get ( i ) + r i g h t _ . get ( i ) ; 8 }; 9 p r i v a t e: 10 c o n s t V e c t o r E x p r e s s i o n & l e f t _ ; 11 c o n s t V e c t o r E x p r e s s i o n & r i g h t _ ; 12 };

Afin de pouvoir calculer les éléments à retourner avec la fonction get, la classe AddVectorExpr doit contenir des références vers les deux expressions à ajouter : left_ et right_, respectivement définies aux lignes10et11. Le calcul de la somme des éléments de left_ et right_ est déporté dans la fonction get définie à la ligne 6. Nous pouvons ainsi éviter l’allocation d’un vecteur temporaire comme le vecteur Z que nous avions utilisé avec les BLAS (cf.section 1.2.2.1).

La classe ScalVectorExpr ressemble beaucoup à la classe AddVectorExpr :

1 c l a s s S c a l V e c t o r E x p r : p u b l i c V e c t o r E x p r e s s i o n { 2 p u b l i c:

3 S c a l V e c t o r E x p r (c o n s t f l o a t & a , c o n s t V e c t o r E x p r e s s i o n & x ) 4 : a_ ( a ) , x_ ( x ) {}

5 v i r t u a l int s i z e () c o n s t { r e t u r n x_ . s i z e () ; };

6 v i r t u a l f l o a t get (c o n s t int i ) c o n s t { r e t u r n a_ * x_ . get ( i ) ; }; 7 p r i v a t e:

8 c o n s t f l o a t a_ ;

9 c o n s t V e c t o r E x p r e s s i o n & x_ ; 10 };

La principale différence réside dans la fonction get (cf.ligne 6) qui multiplie l’élément de l’ex-pression et le scalaire. Notons que x (cf. ligne 9) peut représenter n’importe quelle classe héri-tant de VectorExpression. Les classes AddVectorExpr et ScalVectorExpr permettent donc de représenter n’importe quelle expression vectorielle, aussi longue soit-elle.

L’utilisateur peut maintenant écrire un code comme celui-ci :

1 Vector w ( n ) ,x ( n ) ,y ( n ) ; 2 f l o a t a , b , c ; 3 ... 4 f l o a t r e s u l t = n o r m ( 5 A d d V e c t o r E x p r ( 6 A d d V e c t o r E x p r ( 7 S c a l V e c t o r E x p r ( a , w ) , 8 S c a l V e c t o r E x p r ( b , x ) 9 ) , 10 S c a l V e c t o r E x p r ( c , y ) 11 ) 12 )

Il est aisé de regrouper les classes Vector, ScalVectorExpr, AddVectorExpr ainsi que la fonction norm dans une bibliothèque. Afin de faciliter l’utilisation de cette bibliothèque, le C++ permet de surcharger les opérateurs et ainsi de définir des langages spécialisés à un domaine.

0 1 2 3 4 5 6 7 Puissance de c a lcul (GFLOPS) C MKL Séquentielle Haskell Ocaml MATLAB BOO C optimisé MKL Parallèle Haskell Parallèle BOO optimisée

Figure A.1 : Performances obtenues pour le calcul de la norme ||aW + bX + cY ||.

1 A d d V e c t o r E x p r o p e r a t o r+ ( c o n s t V e c t o r E x p r e s s i o n & l e f t 2 , c o n s t V e c t o r E x p r e s s i o n & r i g h t 3 ) { 4 r e t u r n A d d V e c t o r E x p r ( left , r i g h t ) ; 5 } 6 7 S c a l V e c t o r E x p r o p e r a t o r* ( c o n s t f l o a t & a 8 , c o n s t V e c t o r E x p r e s s i o n & x 9 ) { 10 r e t u r n S c a l V e c t o r E x p r ( a , x ) ; 11 }

Cela permet in fine à l’utilisateur d’une telle bibliothèque d’implémenter la calcul de la norme de la manière suivante :

1 Vector w ( n ) ,x ( n ) ,y ( n ) ; 2 f l o a t a , b , c ;

3 ...

4 f l o a t r e s u l t = n o r m ( a * w + b * x + c * y ) ;

L’utilisateur de cette bibliothèque est alors en droit d’espérer que leurs implémentations soient optimisées. Ce droit est d’autant plus légitime qu’il est relativement aisé de fournir une implémentation parallèle et vectorisée de cette bibliothèque. Le graphique « BOO » de la fig-ure A.1reprend le code présenté ci-dessus tandis que le graphique « BOO optimisé » est obtenu à partir d’une implémentation parallèle et vectorisée par nos soins. Les autres approches men-tionnées correspondent aux approches présentées dans la section 1.2.2

Les performances fournies par cette approche (0,17 GFlops pour la version ci-dessus et 3,6 GFlops pour la version optimisée) sont meilleures que celles fournies par la MKL. Elles restent cependant très inférieures à la version optimisée en C. Ceci est dû au coût des fonctions virtuelles. En effet, les fonctions virtuelles sont implémentées avec des pointeurs de fonction et impliquent donc un surcoût d’indirection. Sur la machine 2×432Nehalem, ce surcoût est estimé à 30 cycles. Ce chiffre est à comparer au nombre de cycles requis pour exécuter le corps de nos fonctions virtuelles (1 ou 2 selon les cas). De plus, l’utilisation de fonctions virtuelles empêche le compilateur d’effectuer des optimisations interprocédurales. Dans cet exemple, cela se traduit par le fait que le compilateur est incapable de vectoriser cette implémentation.

Toutes les informations étant disponibles à la compilation, il est regrettable d’utiliser un mécanisme impliquant un tel surcoût lors de l’exécution. Un outil qui serait capable d’analyser le code et d’effectuer la composition des opérations lors du processus de compilation permettrait d’obtenir l’expressivité souhaitée et n’impliquerait pas de surcoût sur les performances. En C,

le mécanisme de macros permet d’effectuer un certain nombre de transformations du code lors de la compilation. Cependant, il est trop limité pour répondre à notre besoin. Plus récent, le C++ a généralisé ce système avec les patrons (templates) de classe ou de fonction. Beaucoup plus expressifs et fournissant des mécanismes beaucoup plus élaborés, les patrons permettent l’introduction de la programmation générative.

Dans le document Vers des noyaux de calcul intensif pérennes (Page 155-162)