• Aucun résultat trouvé

Coût d'intégration via JNI

La Section 5.2.1 propose un background sur JNI et les diérentes composantes de son coût d'intégration. La Section 5.2.2 décrit les diérents usages de JNI pour des méthodes natives manipulant des tableaux primitifs inuant également sur les performances. Enn la Section 5.2.3 propose de quantier le coût d'intégration intrinsèque via JNI en considérant diérentes signatures de méthodes vides.

5.2.1 Composantes du coût d'intégration

JNI désigne à la fois une spécication [101] et une API C du JDK permettant à du code Java d'appeler des fonctions natives depuis des librairies dynamiques et de manipuler des objets Java depuis ce code natif via des fonctions de rappel (fonctions de rappel JNI). La Figure 5.1 montre la chaîne d'appel lorsqu'une méthode Java-pure appelle une méthode Java native. Le contenu de la méthode native, qui joue le rôle d'interface entre le code Java

Méthode Java appelante (ex. invokestatic) Méthode native (synthétique) Fonction JNI (définie par l'utilisateur) Fonction appelée depuis une librairie externe

Bytecode ou code natif généré par le JIT Code natif compilé statiquement

Appelle Appelle Appelle

Fig. 5.1: Chaîne d'appel JNI depuis une méthode Java vers une fonction native. La mé-thode native est synthétique (i.e. son contenu est généré par le runtime), elle sert d'interface entre le monde Java et le monde natif. La fonction JNI utilise les fonctions de rappel JNI an d'accéder aux données dans la heap puis appel la fonction native depuis une librairie dynamique. Notons que le code natif ciblé peut être déni directement dans la fonction JNI s'il n'est pas dénit dans une fonction d'une librairie externe

et le code natif, est généré par le runtime. L'utilisateur dénit uniquement sa signature relativement à la signature de la fonction native ciblée. La fonction JNI utilise les fonc-tions de rappel JNI an que la fonction ciblée puisse manipuler les objets Java. Le coût d'intégration via JNI a ainsi deux composantes :

1. Le coût de la méthode native (cf. Section 5.2.1.1) ;

2. Le coût des fonctions de rappel JNI (contenu dans la fonction JNI) permettant au code natif de manipuler les structures de données Java (cf. Section 5.2.1.2).

5.2.1.1 Appel de méthode native

La première composante est le coût d'appel vers la fonction native JNI dénie dans la librairie dynamique. Comme montré Figure 5.1, la méthode native Java sert d'interface entre la méthode appelante Java et la fonction native ciblée au sein d'une librairie dyna-mique. La méthode native peut être compilée ou interprétée comme toute autre méthode Java mais elle n'est pas inlinée. Elle eectue principalement les opérations suivantes :

Les références passées en argument sont encapsulées dans des handles1;

Les premiers paramètres de la fonction JNI (i.e. JNIEnv* et jclass dans le cas de méthodes statiques) sont requêtés ;

Les arguments de la fonction native sont copiés selon les conventions d'appel du code natif2;

Le moniteur du receveur est verrouillé dans le cas d'une méthode synchronisée ; Le thread courant bascule de l'état Java à l'état natif ;

La fonction cible JNI est appelée ; L'exécution passe par un safepoint ;

Le thread bascule de l'état natif à l'état Java ;

Le moniteur du receveur est déverrouillé si la méthode est synchronisée ;

Les références produites sont extraites des handles selon les conventions d'appel Java ; Les handles sont libérés.

Le code assembleur généré par le JIT pour ces diérentes étapes peut être visualisé via l'option -XX:+PrintNativeNMethods lorsque la méthode native est compilée.

Dans [95], Sunderam et Kurzinyec ont étudié les performances de JNI pour diérentes signatures d'appel et diérentes implémentations de la JVM. Les résultats montrent des coûts d'invocation avec un facteur de pénalité allant jusqu'à 16 dans les pires cas. Les expérimentations de Murray et al. dans [112] exposent des résultats similaires.

5.2.1.2 Fonctions de rappel JNI

La seconde composante du coût d'intégration provient des fonctions de rappel JNI. Du fait du design machine-indépendant de JNI, les fonctions de rappel JNI sont accédées via un pointeur d'interface (JNIEnv), propre au thread courant, et passé systématiquement comme premier paramètre des fonctions natives. La structure JNIEnv contient un tableau de pointeurs, chacun pointant vers une fonction de rappel JNI. L'invocation d'une fonction de rappel procède ainsi à 2 niveaux d'indirection : le premier pour accéder au pointeur de fonction et le second pour appeler la fonction. Le coût dépend ensuite de la fonction de rappel utilisée.

1Les conventions JNI requièrent que les oops soient contenus dans des handles. Les handles sont des pointeurs mémoire connus du runtime contenant les oops utilisés dans JNI. Ils servent à repérer les racines du GC et à maintenir la validité des oops utilisés dans le code natif à travers le GC. La nécessité des handles provient du fait que, contrairement au code binaire généré par le JIT ou au bytecode, l'emplacement des oops dans le code natif ne peut ni être connu, ni être modié.

5.2.2 Utilisation des tableaux primitifs

Les routines calculatoires considérées (cf. Section 5.5.1) sont implémentées comme des méthodes statiques manipulant des tableaux primitifs. JNI ore 3 manières de manipuler ces structures de données depuis du code natif :

1. Avec transfert des données ; 2. Avec section critique ; 3. Avec mémoire native.

5.2.2.1 Transfert des données

La première manière de manipuler des tableaux primitifs depuis du code natif est d'utiliser les fonctions de rappel JNI <Get/Realease><type >ArrayContents [101]. Ces fonctions déclenchent respectivement des copies des structures Java de la heap vers la mémoire native (ou mémoire hors heap) et inversement de la mémoire native vers la heap. Cette solution s'avère cependant bien trop coûteuse dans le cas de micro-routines pouvant avoir une fréquence d'appel élevée. L'usage de ces fonctions de rappel ajoute un coût asymptotique (et non un coût constant) à la routine comme sa valeur croît linéairement avec la taille des tableaux primitifs copiés.

5.2.2.2 Sections critiques

La seconde manière de manipuler des tableaux primitifs depuis du code natif est d'uti-liser les fonctions <Get/Release>ArrayCritical [101]. Ces fonctions permettent de déli-miter (d'entrer et de quitter) des sections critiques. Dans le contexte de JNI, Une section critique est une section de code natif dans laquelle les structures de données Java peuvent être manipulées sans copie préalable c.à.d directement depuis la heap. L'exécution d'une section critique est cependant restrictive. Les appels à JNI ou les appels systèmes pouvant bloquer le thread courant en attendant un autre thread Java ne peuvent pas être eectués. L'exécution d'une section critique est notamment bloquante pour le GC, ce qui peut en-traîner des eets d'inter-blocage indésirables. Ces restrictions ne rentrent cependant pas dans le cadre de notre usage pour des micro-routines calculatoires.

5.2.2.3 Mémoire native

Enn la troisième manière de manipuler des tableaux primitifs depuis du code natif est d'utiliser directement de la mémoire native. La classe Unsafe du JDK permet de manipuler la mémoire native depuis du code Java, il est ainsi possible d'utiliser du code JNI utilisant directement des structures en mémoires natives préalablement allouées depuis du code Java. L'intérêt de cette méthode est qu'elle s'aranchit du coût des fonctions de rappel JNI et est donc plus performante. Son utilisation au sein des applications s'avère néanmoins délicate comme elle requiert la manipulation de mémoire native depuis le code Java. Par ailleurs, et

en fonction des contraintes applicatives, l'utilisation de la mémoire native peut nécessiter des transferts de données Java vers la mémoire native ce qui peut impacter négativement les performances.

5.2.3 Mesure du coût d'intégration à vide

La Table 5.1 montre les performances comparées des appels de méthodes Java natives par rapport aux appels de méthodes Java-pure ainsi que le facteur de décélération observé. Les techniques d'appel JNI considérées sont avec section critique et avec mémoire native (cf. Section 5.2.2). L'étude ne considère pas les appels avec copie, trop coûteux par rapport aux deux autres approches.

Les valeurs mesurées représentent le débit d'appel en millier d'appels/seconde pour des méthodes à vide. Diérentes signatures des paramètres d'entrée sont considérées. Le débit dépend du nombre de paramètres d'entrée pour toutes les implémentations. La variation est néanmoins moins sensible pour les implémentations Java et JNI avec mémoire native. On peut observer la diérence de coût d'intégration entre JNI avec section critique et JNI avec mémoire native, d'environ 89%, provenant des fonctions de rappels pour entrer et sortir d'une section critique lorsque la routine manipule des tableaux primitifs. L'impact des fonctions de rappel augmente avec le nombre de tableaux primitifs passés en argument. Le facteur de décélération dû uniquement au coût d'appel à JNI sans les fonctions de rappel est observé en comparant Java et JNI avec mémoire native et est d'environ 75%. L'impact du coût d'intégration est, dans des cas réels, à relativiser par rapport au contenu de la méthode appelée. Néanmoins, lorsque la quantité de calcul devient nulle les performances se rapprochent théoriquement du coût d'intégration à vide.

Java

non inlinée JNIsection critique JNImémoire native

() 600 147 (-75%) 147 (-75%)

(double[]) 595 25 (-96%) 147 (-76%)

(double[], double[]) 580 14 (-97,6%) 143 (-76%)

(double[], double[],

double[]) 520 9 (-98,5%) 131 (-75%)

Tab. 5.1: Débit d'appel de méthodes statiques vides (en millier d'appels/seconde) pour diérentes signatures des paramètres d'entrée. La valeur entre parenthèse correspond au facteur de décélération par rapport à Java