JVM (Java Virtual Machine) – #3

Cet article est le dernier de la série sur la Java Virtual Machine qui présente les différents résultats en termes de taille de méthode et de vitesse d’exécution avec au moins 6 JDK différents. Le but est de monter l’influence de la taille des méthodes sur la compilation JIT et la vitesse de traitement.

A) Test sur l’influence de la taille des méthodes

Voici un test contenant de mauvaises pratiques sur la taille des méthodes. Le code possède volontairement une initialisation de tableau très longue (remplie de « Double.valueOf(« 445″), … ») dans les méthodes à tester.

public static void tresGrosseMethode17643opcode() {double[] testBidon = new double[] {Double.valueOf(« 445 »), Double.valueOf(« 445 »),….. } ;                       double sum = 0.0d;final int taille = 100;for (int i = 0; i < 10000; i++) {

for (int idx = 0; idx < taille; idx++) {

sum += testBidon[idx];

sum /= 1.00001d;

}

}

}

Bilan sur le test de vitesse d’exécution selon la taille des méthodes :

  • Plus les JVM sont récentes (java 7 et +), plus le gain en réduisant la taille des méthodes est perceptible.
  • La taille idéale d’une méthode semble être vers les 80 à 300 octets de bytecode qui est difficilement traduisible en nombre de lignes mais disons autour des 20 à 30 lignes de code. Cependant, sans JVM java 7 ou 8 en -server, aucune amélioration de vitesse ne sera constatable.
  • Le découpage à l’extrême des méthodes en sous-méthodes plus petites peut donner de mauvais résultats surtout avant compilation et optimisation globale de l’ensemble. Après une phase de « warm up » et lorsque la JVM le permet, les temps de réponse sont optimisés.
  • Oracle Java 6 aussi bien 32bits que 64bits n’arrive pas à optimiser des méthodes de taille intermédiaire (entre 1 et 7999 octets de bytecode)
  • L’option « -XX:DesiredLimitMethod=xxxx» activable uniquement en JDK 1.6 n’a aucune influence directe sur la rapidité d’exécution des méthodes contrairement à ce que l’option laisse supposer.
  • Pour Oracle Java 7, entre la version -client et -server, il y a une différence de vitesse de 770 en faveur du mode -server. De même, Java 7 (en mode -server) a des performances bien meilleures que Java 6.
  • Sur la JVM Oracle, les paramètres de tuning de l’inlining suivants semblent inefficaces : -XX:-ClipInlining, -XX:InlineSmallCode=xxx et -XX:MaxInlineSize=xxx et n’ont aucune influence directe.
  • De même, l’inlining s’arrête lorsque la limite de « DesiredLimitMethod » (8Ko) de bytecode est atteinte. La JVM parle de « clipping » de l’inlining. En conséquence, si les méthodes fusionnées arrivent au total à moins de 8Ko, elles seront plus rapide que lorsqu’on a atteint la limite des 8Ko et qu’aucun inlining ne peut plus arriver en fin de méthode appelante.
  • Depuis la version 7 (et supérieure), le paramètre « DesiredLimitMethod est en dur dans globals.hpp du code source du JDK (voir ligne 3553).
  • L’option de compilation « -XX :TieredCompilation et -XX :TieredStopAtLevel=1 à 4 » est dépendante du -client ou -server. Globalement les performances sont meilleures avec le niveau 4 (qui est la valeur par défaut).
  • Sur la JVM java 7 d’Oracle, une amélioration de vitesse se fait sentir lorsque celle-ci est en dessous du seuil de 2Ko de bytecode (jusqu’à 3 à 4 fois plus rapide par rapport à des méthodes comprises entre 2Ko et 8Ko) et encore plus vers 300 à 80 octets de bytecode.
  • Sur la JVM 1.6 d’IBM (J9) en 32bits, dès que la taille d’une méthode arrive en dessous du seuil de 4870 octets de bytecode, la méthode voit sa vitesse s’améliorer d’un facteur 54 minimums (268 fois après un warm up sur le test effectué).
  • Sur la JVM 1.6 d’IBM (J9), le « warm up » d’une méthode fortement découpée peut être lent mais une fois en place, la méthode peut être jusqu’à plus de 1000 fois plus rapide que si la méthode dépasse 4870o de bytecode.
  • Sur la JVM IBM en java 7 64bit, il n’y a pas de limitation de taille sur les méthodes à compiler en code assembleur. Les méthodes sont plus rapides qu’en Java 6 IBM. De même que pour Java 7 Oracle (en -server), en dessous de 2Ko de bytecode, les méthodes sont encore plus rapides.
  • La JVM Jrockit java 6 32bit a également un seuil à 4Ko de bytecode où lorsqu’une méthode est en dessous de quelques octets de code, elle voit sa vitesse augmenter par 10 minimum.

B) Test sur l’influence de la taille des méthodes appelantes et appellés sur l’inlining

Voici un autre test pour vérifier la réaction de l’inlining lors de fusion entre de grandes méthodes appelées ou appelantes avec des petites méthodes appelantes ou appelées.

public static void step1_grandCallerNotInline1165opcode() {double[] tmp = new double[] {Double.valueOf(« 445 »), Double.valueOf(« 445 »),Double.valueOf(« 445 »), Double.valueOf(« 445 »),…. Encore beaucoup …..};double[] testBidon = Arrays.copyOf(tmp , 100 );

for (int i = tmp.length; i < 100; i++) {

testBidon[i] = Double.valueOf(« 445 »);

}

step1_petitCalleeNotInline52opcode(testBidon);

}

public static void step1_petitCalleeNotInline52opcode(double[] testBidon) {

double sum = 0.0d;

final int taille = 100;

for (int i = 0; i < 10000; i++) {

for (int idx = 0; idx < taille; idx++) {

sum += testBidon[idx];

sum /= 1.00001d;

}

}

}

Bilan sur le test de vitesse d’exécution selon la taille des méthodes appelantes et appelées :

  • Indépendamment des versions de JDK, si la taille de la méthode appelée ou appelante devient trop grande, la vitesse est moindre.
  • Sur l’ensemble des tests sur les JVM, le résultat est plus homogène lorsqu’on utilise une méthode appelante plus petite que la méthode appelée. Celle-ci peut être plus grande ou de taille quasi équivalente à la première.
  • Même avec plusieurs essais et une séance de warm up, les résultats sont parfois surprenants, il est donc peu évident de tirer des conclusions définitives.
  • En général, les JDK 32 bits (6 et 7) en mode « -client » sont incapables d’accélérer les petites méthodes appelés ou appelantes.

C) Test sur l’influence de méthode courte et du full inlining

Le dernier test consiste à vérifier le comportement de la JVM lorsque les méthodes sont suffisamment courtes (environ moins de 6 lignes de code). En effet, les méthodes ne dépassant pas 35 octets de bytecode de code sont éligibles à un « Full Inlining » qui est une fusion directe des méthodes appelées et appelantes. L’idée du test est de vérifier le comportement de la JVM et sa capacité à fusionner plusieurs petites méthodes et l’influence des paramètres lors des appels de méthodes

private static double[] step1_fullInlinableCreate2Values_Test1() {return new double[] {Double.valueOf(« 445 »), Double.valueOf(« 445 »)};}private static void step1_fullInlinedFillArrayInLoop_Test1(final int nbElementsCrees,double[] testBidon, int i) {double[] tmpArray = step1_fullInlinableCreate2Values_Test1();

for (int idx=0; idx < nbElementsCrees; idx++) {

testBidon[(i*nbElementsCrees)+idx] = tmpArray[idx];

}

}

/**

* Test inling avec le corps de la méthode dans la boucle + plusieurs paramètres en E/S….

*/

public static void step1_methodeAvecTresPetitInlining_Test1() {

final int nbElementsCrees = step1_fullInlinableCreate2Values_Test1().length;

final int maxElements = 4200; // supérieur à 8000

double[] testBidon = new double[maxElements*nbElementsCrees];

for (int i = 0; i < maxElements; i++) {

step1_fullInlinedFillArrayInLoop_Test1(nbElementsCrees, testBidon, i);

}

double sum = 0.0d;

final int taille = 100;

for (int i = 0; i < 10000; i++) {

for (int idx = 0; idx < taille; idx++) {

sum += testBidon[idx];

sum /= 1.00001d;

}

}

}

Bilan sur le test de vitesse d’exécution selon la taille des méthodes appelantes et appelées :

  • De manière générale, à trop découper les méthodes dans lesquelles on a des « for » imbriqués, on a des contre-performances. Exemple : 1 méthode de 30 lignes découpées en 5 méthodes de 6 lignes. On arrive aux limites de l’inlining des JVM actuelles et c’est finalement au moins 20 fois plus lent que des méthodes de taille intermédiaire d’environ 20 à 30 lignes de code.
  • Plus on prend une version récente du JDK, plus c’est optimisé pour les petites tailles de méthodes.
  • Il y a peu de différences dans l’appel des méthodes si la boucle for est en dehors ou à l’intérieur de la méthode appelée.
  • Il y a peu de différences si l’appel d’une méthode utilise le passage par plusieurs paramètres ou le passage d’une référence de type databean. Sauf pour le JDK 7 et 8 en 64 bit où, après warm up, l’utilisation d’un databean ou d’attributs privés semble être meilleurs que l’envoi de tableau en paramètre.

Bilan sur l’ensemble des 3 tests :

  • Les options Oracle qui marchent (mais à ne pas désactiver) : désactivation de l’OSR (-XX:-UseOnStackReplacement), désactivation d’une partie de l’inlining (-XX:-Inline), désactivation de l’Escape Analysis (-XX :-DoEscapeAnalysis)
  • Sur la JVM Oracle, l’option -XX:+TieredCompilation ne réagit pas pareil lorsqu’on force -client ou -server. Dans la majorité des tests, les résultats sont meilleurs avec –server
  • La compilation AoT (via -Xcomp ou IBM -Xjit:count=0) n’est pas la meilleure idée pour de bonnes performances car toutes les méthodes sont compilées à la 1ère exécution. Il faut un temps de warm up beaucoup plus long pour des applications lancées avec cette option. L’option par défaut  « -Xmixed » reste le meilleur compromis.
  • Si vous travaillez sur une JVM en OS virtualisé, vérifiez bien que le mode SSE4 et 4.2 du CPU sont activés, le risque est de perdre en temps de réponse lors de calculs intensifs.
  • Le paramètre pour changer le seuil de 1Ko pour l’inlining semble ne pas fonctionner ou être inefficace car il n’y a aucun changement notable quand on le change
  • La JVM a besoin d’un “warm up” pour tirer parti de sa quintessence. C’est à prendre en compte pour des tests de performances Web ou batch et également pour des micro-benchmarks.

Pour information, en pièce jointe, se trouvent le code source des classes de tests. Je n’ai pas cherché à le rendre « beau » mais  j’y ai mis l’essentiel pour les tests à effectuer.

Conclusion générale sur l’optimisation de la JVM

Le sujet est vaste et finalement très peu connu et étudié. En effet, c’est d’abord le paramétrage du Garbage Collector et de la mémoire qui seront les premiers facteurs d’accélération d’une application fonctionnant sur la JVM.

Dans la majorité des cas, une réduction de la taille de la méthode en la descendant jusqu’à 20 à 30 lignes de code va apporter un gain de vitesse notable sur Java 7 et supérieur. Cependant, pour que l’application soit elle-même plus rapide, il faut que la méthode « refactorée » soit réellement appelée souvent et soit un goulet d’étranglement pour le reste de l’application.

Après avoir modifié le code source de quelques projets Open Source (base de données H2 et Derby ainsi que d’autres projets) en ayant découpé quelques grosses méthodes en blocs plus fins, des gains ont été perçus mais pas assez significatifs.  Etait-ce aussi que les tests sur des bases de données mémoires étaient quand même fortement liés aux entrées/sorties ?  Je compte poursuivre d’autres tests sur des projets moins dépendants des I/O.

Toujours est-il qu’il faut se méfier du code produit par des générateurs de code java ou de bytecode. Autant le compilateur scalac crée des petites classes et des petites méthodes, autant les jrubyc et le compilateur jsc de Rhino créent sans option une grosse méthode comportant l’ensemble du programme à exécuter :  la méthode principale peut vite dépasser le seuil de 2Ko de bytecode et se retrouver beaucoup plus lente que si le code généré était mieux découpé.

Ce constat est le même pour les JSP qui peuvent vite se retrouver avec une méthode « _jspService() » très grosse. Cependant les JSP ne produisent en général qu’un ajout de chaînes de caractères dans un buffer et même en interprété cela reste encore rapide.  Selon, les tests effectués, la JVM utilisait moins de CPU sur des JSP dont la taille était « optimisée » pour une compilation assembleur mais les temps de réponse globaux étaient les mêmes qu’avec des JSP plus grosses.

Dans l’article « Au cœur des optimisations de code de la JMV », j’ai proposé les premières briques d’un outil capable de calculer automatiquement la taille des méthodes des classes. Dans une prochaine série d’articles, nous verrons  comment tirer parti du “JITspector” : la version améliorée de l’outil qui analyse la taille des méthodes lors de l’intégration continue. Cette 2ème série aura également pour but de montrer la démarche de refactoring de méthodes trop grosses pour se concentrer que sur celles les plus sollicitées afin de les optimiser de manière adéquate.

Architecte JEE

0 commentaires

votre commentaire

Se joindre à la discussion ?
Vous êtes libre de contribuer !

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Inscription newsletter

Ne manquez plus nos derniers articles !