• Aucun résultat trouvé

2.2 La langage Java

2.2.3 Limitations pour le HPC

Les limitations du Java pour le calcul haute performance peuvent être séparées en diérents types. Dans cette étude les limitations qui nous intéressent plus particulièrement sont celles concernant l'optimisation de code soulignées Section 2.2.3.1. On peut par ailleurs citer les limitations fonctionnelles ainsi que celles liées à la gestion mémoire. Ces dernières sont soulignées Section 2.2.3.3.

2.2.3.1 Limitations pour l'optimisation de code

Expressivité Les limitations du Java pour l'optimisation de code proviennent princi-palement de la représentation des programmes sous forme de bytecode. Le bytecode est un format compact (une instruction étant encodée sur un byte) ce qui réduit l'empreinte mémoire des programmes mais limite également le nombre d'instructions encodables à 256. Pour favoriser la portabilité, le bytecode exprime des opérations fonctionnant sur une ma-chine à pile.

La problématique du bytecode concernant l'optimisation de code est son manque d'ex-pressivité vis à vis du langage machine. L'exd'ex-pressivité d'un langage vis à vis d'un langage cible mesure sa capacité à exprimer une sémantique exprimable dans le langage cible. Ce manque d'expressivité s'observe par exemple en comparant le nombre d'instructions by-tecode (environ 200 exprimant une sémantique haut-niveau) et le nombre d'instructions machines (plus de 600 pour le jeu d'instructions x86-64).

spé-ciaux comme les registres de drapeaux et les compteurs matériels directement depuis du bytecode ou encore l'incapacité d'utiliser certaines instructions qui pourraient permettre d'améliorer les performances. On peut par exemple citer l'instruction IMUL eectuant une multiplication entière sur 128 bits considérée dans le Chapitre 6.

Spécications Le bytecode exprime par ailleurs les spécications du langage Java qui peuvent s'avérer contraignantes pour les performances. On pense aux vérications de sécu-rité générées implicitement. Par exemple le bound-check est eectué par les bytecodes du type <type >a<store/load>19 et vérie que l'index de l'élément accédé est valide. Diérentes techniques sont utilisées par les compilateurs dynamiques an d'éliminer le bound-check [194,195,12]. Le null-check est lui eectué par diérents bytecodes comme invokevirtual, utilisé pour l'invocation de méthode, et vérie que l'objet receveur ne vaut pas null. Le null-check est néanmoins masqué dans certain où cas il peut être eec-tué implicitement (i.e. sans test explicite) en interceptant les exceptions matérielles levées par certaines instructions comme MOV [86]. Le null-check implicite est implémenté dans HotSpot (cf. Section 4.2.2.3 Chap. 4). Un autre exemple de contrainte provenant des spéci-cations est montré Chapitre 6 concernant le bytecode d2i eectuant une troncature d'un double vers un int.

Limitations du compilateur En dehors de l'expressivité du bytecode, l'autre limita-tion concernant l'optimisalimita-tion de code provient des capacités du compilateur dynamique. Comme précisé Section 2.3, le compilateur dynamique doit, contrairement à un compilateur statique, géré le compromis qualité du code généré /durée d'optimisation. Cette considé-ration se traduit généralement par une contrainte sur la durée allouée à l'optimisation de code et certaines optimisations comme l'allocation de registre ou la vectorisation privilé-gient ainsi des algorithmes rapides au détriment de l'optimalité du code généré [98, 186]. Concernant la JVM HotSpot, l'auto-vectorisation a été introduite dans Java 7 mais s'avère, y compris dans Java 8, très limitée comme montré dans le Chapitre 5. De nombreuses évo-lutions sont à venir avec Java 9 comme par exemple le support de l'extension AVX-512 [123], la vectorisation de l'intrinsic Math.sqrt [131] ou la vectorisation des réductions [125]. Intrinsics Une des solutions pour augmenter l'expressivité du langage ou contourner les limitations du compilateur est de faire usage de fonctions dites intrinsèques ou intrinsics (également appellées builtin dans le contexte de GCC). Ces fonctions permettent dans le cas de GCC d'exprimer directement un grand nombre d'instructions machines. Néan-moins concernant Java, diérentes contraintes comme la portabilité limitent la dénition de fonctions représentant des instructions machine-spéciques et donc l'usage d'intrinsics

19Il s'agit des bytecodes utilisés pour lire (load) ou écrire (store) un élément dans un tableau primitif. "type " vaut respectivement a, d, f, l, i, s, b pour un élément de type Object, double, float, long, int, short, byte.

pour faire de l'optimisation de code. Ces problématiques sont abordées plus précisément Chapitre 6.

2.2.3.2 Limitations liées à la gestion mémoire

Les limitations liées à la gestion mémoire sont multiples. Elles peuvent en premier lieu provenir de limitations du ramasse-miette. Des études ont par exemple montré que la gestion mémoire automatique pouvait occasionner dans certains cas des pertes de perfor-mance importantes par rapport à la gestion mémoire manuelle [70,14]. Le ramasse-miette de HotSpot a également montré des faiblesses provenant d'accès distants intensifs lors des collections sur des machines à accès mémoire non uniforme (NUMA) pour des heaps excédant 100 Gbytes (notons que les eets NUMA sont peu signicatifs dans le cas de l'application Inscale qui privilégie des instances de JVM dont les heaps sont inférieures à 32 Gbytes, notamment pour bénécier de pointeurs compressés). Des améliorations ont été apportées dans Java 7 pour ce type d'architectures [121]. Une étude a néanmoins mon-tré que des améliorations signicatives pouvaient encore être apportées pour des machines NUMA de 256 à 512 GBytes de mémoire [58].

Le modèle mémoire Java est par ailleurs la source d'une consommation mémoire accrue due à la présence systématique des entêtes d'objet (ou header). Sur certaine JVM comme HotSpot l'empreinte des headers est réduite pour les architectures 64-bits grâce à l'utilisa-tion de pointeurs d'objets compressés (cf. Secl'utilisa-tion 2.3.1.2). La sur-consommal'utilisa-tion mémoire peut être accentuée par la présence de zones de padding. Ces dernières proviennent des contraintes d'alignement dues par exemple aux spécications Java qui déclarent que tous les objets doivent être alignés sur 8 bytes [60].

La sur-consommation mémoire est d'autant plus signicative que la taille de l'objet en mémoire est petite, elle est ainsi importante dans le cas des objets encapsulant des types primitifs. La Table 2.1 montre la sur-consommation mémoire dans HotSpot des objets en-capsulant les types primitifs par rapport aux types primitifs.

Cette sur-consommation peut avoir pour eet de bord de réduire la localité mémoire. Cer-taine technique comme la collocation d'objet ont été étudiées an de réduire l'empreinte des headers, le principe étant de factoriser un header pour plusieurs objets [198].

Une des contraintes fondamentales du modèle mémoire Java est l'impossibilité de contrôler nement la couche mémoire des structures de donnée à savoir l'organisation et l'emplacement des informations en mémoire. Elle peut avoir pour conséquence une perte de localité mémoire.

Cette perte de localité peut prendre eet au niveau de la hiérarchie mémoire NUMA. An d'assurer la localité au niveau NUMA des gestionnaires d'allocation dits "NUMA-Aware" [58,174] ont été mis au point. C'est par exemple le cas dans la JVM HotSpot.

Type objet vs. type primitif Poids mémoire type objetvs. type primitif [Bytes] Sur-consommationdu type objet Long vs long Double vs double 24 vs. 8 +200% Integer vs int Float vs float 16 vs. 4 +300% Short vs short 16 vs. 2 +700% Byte vs byte 16 vs. 1 +1500%

Tab. 2.1: Sur-consommation mémoire dans HotSpot entre les objets encapsulant les types primitifs et les types primitifs. Les tailles sont calculées en mode pointeur-compressé (cf. Section 2.3.1.2) et considèrent le padding d'alignement des objets sur 8 bytes

provient en particulier de l'impossibilité en Java de dénir des structures de données du type tableaux de structures qui permettent de garantir une la localité (mais également de favoriser la vectorisation) [42]. Le modèle mémoire impacte en corollaire de la localité les performances du code comme il implique de nombreuses indirections pour accéder aux données, ce phénomène est connu sous le nom de pointer chasing.

An de réduire l'impact du pointer-chasing et d'augmenter la localité, des solutions auto-matiques comme l'inling dynamique d'objets (ou la collocation dynamique d'objets) ont été étudiées [188].

L'impossibilité de contrôler l'emplacement mémoire des objets peut entraîner des pro-blèmes de false-sharing liés à la cohérence de cache ainsi que des propro-blèmes d'alignement des données pouvant occasionner des pertes de performances sur des routines vectorisées (cf. Chap. 5). Dans Java 8 l'introduction de l'annotation @Contended [126] permet d'éviter le false-sharing grâce à un support du ramasse-miette et du gestionnaire d'allocation.

Enn le modèle mémoire Java peut occasionner des allocations en heap excessives provenant du fait que le langage ne supporte pas d'alternatives aux objets en dehors des types primitifs (ces allocations excessives ont par ailleurs pour eet d'alourdir la charge du ramasse miette). Ainsi des valeurs autres que des types primitifs utilisées localement (i.e. sans échapper au scope dans lequel elles sont dénies) et qui pourraient donc résider sur la pile ou dans des registres sont nécessairement allouées en heap sous la forme d'objet. Les optimisations du compilateur dynamique telles que l'analyse de localité ou le remplacement scalaire [156] permettent d'éviter les allocations en heap lorsque c'est possible. Par ailleurs l'introduction des value-types (prévue pour Java 10) va également favoriser la réduction du coût des allocations dans les applications Java [172].

2.2.3.3 Autres limitations

Limitations fonctionnelles Parmi les limitations fonctionnelles on peut par exemple citer l'indexation des tableaux primitifs avec des entiers 32-bits signés qui limite le nombre d'élément d'un tableau primitif à 231−1 au lieux de 264−1 avec des entiers 64-bits non

signés. On peut également citer certaines spécications du standard IEEE 754 [18] pour le calcul ottant qui ne sont pas respectées à savoir le support de tous les modes d'arrondis dénis avec la capacité de le modier en cours d'exécution ainsi que la capacité à lever des exceptions telles que overow ou underow lors des dépassements. Enn on peut citer l'absence de support pour les tableaux primitifs multidimensionnels et les nombres complexes, qui a été pointée du doigt comme une limitation du Java pour le calcul haute performance [109].

Type générique Un des aspects souvent pointé du doigt en Java est l'absence de spé-cialisation concernant la programmation générique. En C++, la programmation générique via les templates permet de programmer des fonctions génériques qui seront par la suite spécialisées par le compilateur en fonction des usages faits dans l'application. Il y aura donc autant de versions qu'il y a d'usages diérents. En Java, la méthode ou la classe générique est réellement générique, l'implémentation générique reposant sur la notion d'héritage (cf. Section 2.2.4). Un code générique en Java peut par conséquent être sous-optimal en faisant intensivement usage de polymorphisme et de checkcast20. Une seconde conséquence de la généricité basée sur l'héritage provient du fait qu'elle s'applique uniquement aux types non primitifs c'est-à-dire aux objets. Le projet OpenJDK Valhalla [172] a notamment pour objectif d'intégrer la spécialisation des types génériques en Java (et par conséquent l'ex-tension aux types primitifs). Ces améliorations sont attendues pour Java 10.

Cette limitation ne gure pas dans les limitations pour l'optimisation de code. En eet, comme la généricité se fait au détriment des performances, son gain de productivité n'est pas protable sur du code critique.