• Aucun résultat trouvé

Routines utilisant des structures de données complexes

5.4 Types de routines considérés et optimisations natives

5.4.1 Routines utilisant des structures de données complexes

On entend par structures de données complexes, toute structure de données autre que les tableaux primitifs. La particularité des routines utilisant ces structures de données, est que leur intégration via JNI nécessite le transfert des données d'entrée depuis la heap vers la mémoire native et, si il y en a, le rapatriement des données de sortie depuis la mémoire native vers la heap. Cette copie est nécessaire car ces dernières ne peuvent pas être lues

20 21 22 23 24 25 26 27 28 29 210 211 212

Quantité de calcul par appel (log2)

0.0 0.2 0.4 0.6 0.8 1.0 Performance normalisée)

Implémentation avec une performance asymptotique plus faible mais un cout constant plus faible Implémentation avec une performance asymptotique plus élevée mais un cout constant plus élevé

Fig. 5.3: Performance normalisée en fonction de la quantité de calcul pour deux implémen-tations diérentes. On observe un point de croisement, la meilleur implémentation dépend de la quantité de calcul

ou écrites en-place (i.e. directement dans la heap) par du code natif. Par ailleurs, les dé-nitions natives et Java de ces structures peuvent diérer. Pour ces routines, l'utilisation directe de mémoire native est une piste permettant d'éviter les transferts de données. Cette solution bouleverse néanmoins le paradigme de programmation et peut nécessiter la modi-cation de tout le ow de traitement. Elle s'avère donc coûteuse et risquée d'un point de vue développement.

Le coût du transfert des données s'ajoute au coût asymptotique et non au coût constant comme ce dernier dépend de la taille de la structure de données transférée qui est un facteur d'échelle. Par ailleurs, le coût constant n'est pas seulement composé du coût d'intégration via JNI mais également du coût constant propre à la routine, composé d'opérations algo-rithmiques, qui peut éventuellement masquer le coût d'intégration via JNI. Ainsi, pour ce type de routine, la principale contrainte à l'utilisation de JNI est le coût de transfert des données.

Le transfert des données peut se faire de deux manières. Depuis du code natif ou depuis du code Java. L'implémentation Java utilise la classe Unsafe pour écrire ou lire la mémoire native. Elle requiert la connaissance de l'agencement mémoire de la structure de donnée native. La seconde utilise les fonctions de rappel JNI depuis du code natif. L'implémen-tation Java s'avère plus performante ce qui s'explique par le coût élevé des fonctions de rappel JNI, en particulier les fonctions GetObjectArrayElement et DeleteLocalRef [101], permettant de créer et de libérer une référence locale vers un objet depuis un tableau, pour lire ou écrire des champs d'objet.

L'optimisation de telles routines applicatives peut se faire par portage du code Java en code C++ (cf. Section 5.4.1.1) en déléguant l'eort d'optimisation sur le compilateur na-tif, comme g++, et sur la dénition des structures natives. Le gain de performance mesuré provient alors d'une meilleure optimisation statique du code par g++ par rapport à l'op-timisation dynamique faite par C2, d'une meilleure expressivité du C++ par rapport au Java et/ou d'accès mémoire plus rapides dus à l'utilisation de structures native amélio-rant la localité des données [42]. En dehors du portage, JNI permet également l'appel à des librairies natives optimisées (cf. Section 5.4.1.2). Un des avantages majeurs du code natif est l'existence de librairies micro-optimisées pour une architecture donnée, telle que FFTW, à la diérence des librairies Java dont les optimisations se limitent à la couche algorithmique.

5.4.1.1 Portage en C++ d'un algorithme géométrique

Le portage de code Java a été eectué sur la routine Manhattan healing3 qui implé-mente un algorithme d'union de polygone rectilinéaires par la méthode sweepline (ligne de balayage) [104]. Un exemple d'union de polygone rectilinéaire est montré Figure 5.4 où les polygones A, B, C et D sont unis pour former le polygone rectilinéaire E. La routine utilise

B A C D Entrée Sortie E Manhattan Healing

Fig. 5.4: Exemple d'union de polygone rectilinéaire, eectuée par la routine Manhattan healing. Les polygones d'entrée A, B, C et D sont unis pour former le polygone de sortie rectilinéaire E

une structure déjà triée en entrée implémentée en Java sous forme un tableau d'objet. L'ob-jet nommé Event contient trois champs primitifs, deux entiers 64-bits correspondant aux coordonnées du point et un entier 32-bits caractérisant le type d'intersection. L'équivalent C++ utilisé pour cette structure est un std::vector de structure Event. La structure de sortie Java est une ArrayList de polygones ou un polygone est stocké sous la forme d'un

3Le terme Manhattan est utilisé en géométrie pour désigner des formes rectilinéaires et fait référence aux rues de Manhattan qui décrivent un réseau rectilinéaire.

tableau primitif d'entier 64-bits correspondant à ses coordonnées. L'équivalent C++ de la structure de sortie est une std::list contenant les pointeurs vers les tableaux d'entiers formant les polygones. Le transfert des données entre structure Java et structure native est implémenté en Java en utilisant la classe Unsafe pour lire et écrire la mémoire native. Cette implémentation nécessite de connaitre la dénition mémoire de la structure native. Le design d'entrée considéré est une grille carrée formée par des carreaux se touchant. Pour ce type d'entrée, le design de sortie calculé par la routine est un carreau formé par les bords de la grille (cf. Figure 5.5). Pour le design d'entrée considéré, la complexité de l'algorithme est en O(N log(N))où N est le nombre d'arêtes du design d'entrée. Les performances de la routine sont calculées par la formuleN log(N)/dt(oùdtest la durée d'exécution) expri-mées en op/s (opérations par seconde).

La Figure 5.6 montre les performances obtenues par micro-benchmark pour les implémen-tations Java et JNI. L'implémentation JNI comporte deux variantes : avec et sans transfert de mémoire. Dans le premier cas le temps de transfert des données est mesuré, dans l'autre les données sont déjà en mémoire native. La version JNI sans transfert permet une amélio-ration des performances de 233% en moyenne sur les taille de grille considérées alors que pour la version JNI avec transfert, on observe une baisse de -20% par rapport à la version Java. Pour cette routine, le transfert constitue une part importante du temps global de calcul. Ainsi l'utilisation de JNI n'est bénéque que si l'intégration permet d'utiliser la version sans transfert.

Entrée Sortie

Manhattan Healing

Fig. 5.5: Exemple de design d'entrée considéré pour la routine Manhattan Healing (grille de taille 2x2) avec la sortie correspondante. Quelques soit la taille de la grille la sortie est un carreau unique. Les arêtes en rouge représentent les intersections des carreaux. Ce design d'entrée est un cas particulier d'intersections comme ces dernières ne sont pas des points mais des segments. La complexité de l'algorithme pour ce design est enO(N log(N)) oùN est le nombre d'arêtes

500x500 700x700 1000x1000 1400x1400 2000x2000 2800x2800 4000x4000

Taille de la grille

0

2

4

6

8

10

Performance [Gop/s]

Implémentation Java JNI JNI sans transfert

Fig. 5.6: Performance des implémentations de la routine Manhattan healing pour dié-rentes tailles de grille

5.4.1.2 Utilisation de la librairie FFTW

En dehors du portage de code Java, l'autre moyen de mettre à prot JNI est l'utilisa-tion de librairies natives. JNI permet de tirer partie de librairies natives micro-optimisées pour une architecture donnée, à la diérence des librairies Java dont les optimisations sont essentiellement de nature algorithmiques (en dehors des librairies Java qui sont gérées par le runtime).

La librairie FFTW a été testée pour la routine de calcul de transformée de Fourier dis-crète (TFD) réelle en 2 dimensions calculant en double-précision et en-place (i.e. la matrice d'entrée et la matrice de sortie sont le même conteneur mémoire).

La version JNI utilise FFTW en mode FFTW_ESTIMATE. FFTW exécute un plan qui est le couple données/algorithme. Ce mode permet de sélectionner rapidement avec une heu-ristique simple l'algorithme de TFD adapté pour les données d'entrée, sans tester dié-rents algorithmes. L'algorithme résultant est néanmoins potentiellement sous-optimal. Un plan peut être utilisé pour des données diérentes si ces dernières répondent à certaines contraintes (alignement, taille et agencement mémoire) ce qui permet de rentabiliser la création d'un plan, en particulier pour les autres modes nécessitant plus de temps de créa-tion du plan. Les implémentacréa-tions JNI comportent là encore les deux variantes avec et sans transfert des données. Le temps de transfert contient le temps de copie des données vers la mémoire native, le temps de création du plan FFTW et pour nir le temps de copie

du résultat vers la heap. La version sans transfert ne mesure que le temps d'exécution de la routine.

La version Java est une version séquentielle issue de la librairie open-source JTransforms utilisée dans diérents projets scientiques Java [181].

La Figure 5.7 montre les résultats de performance des implémentations Java et JNI. La version JNI sans transfert permet un gain de performance en moyenne de 60% par rapport à Java sur les tailles de matrice considérées tandis que la version avec transfert permet un gain de 27%. Là encore le transfert des donnée limite de manière signicative les per-formances. Le gain de performance de 27% peut être considéré comme trop faible pour justier l'utilisation de méthode native au sein de l'application.

256x256 512x512 1024x1024 2048x2048 4096x4096 8192x8192

Taille des matrices

0

500

1000

1500

2000

2500

3000

3500

4000

Performance [Mflops/s]

Implémentation Java JNI JNI sans transfert

Fig. 5.7: Performance de la TFD en 2 dimensions pour diérentes tailles de matrice