juin
2006
Dans une discussion récente sur le forum Java concernant la génération d’un exécutable, j’ai indiqué qu’un code natif n’étais pas forcément plus rapide que du bytecode, et que cela pouvait même être l’inverse puisque les JVM actuelles utilisent un compilateur JIT.
Comment cela pourrait-il être possible ?
Lorsqu’on compile un code natif, les compilateurs n’activent pas toutes les optimisations possibles afin que le programme puisse s’exécuter sur des machines ne disposant pas forcément du même type de matériel (et particulièrement le CPU).
A l’inverse, le bytecode Java est compilé dynamiquement à l’exécution par le compilateur JIT. Ce dernier peut donc prendre en compte les spécificités de la machine. Le gain de performance peut être très important…
Je vais donc essayer de faire quelques tests afin de comparer les temps d’exécution…
Environnement de test
Les tests ont été effectué sur un Pentium 4 HT 3.00 Ghz avec 1 Go de RAM.
Chaque test consiste en une boucle de 100 millions d’itérations sur des opérations simples, afin d’obtenir un temps d’exécution significatif. Le temps d’exécution est récupéré de deux manières différentes, avec deux appels à System.currentTimeMillis() autour de la boucle, mais aussi via un programme appelant afin de prendre en compte le temps de démarrage de la JVM et du chargement du programme natif. Chaque test sera exécuté cinq fois afin de limiter les erreurs potentiels.
- La JVM 5.0 de Sun en mode interprété (option -Xint).
- La JVM 5.0 de Sun en mode client.
- La JVM 5.0 de Sun en mode Server (option -server).
- La JVM 6.0 de Sun en mode client.
- La JVM 6.0 de Sun en mode Server (option -server).
- Un code natif généré par GCJ.
- Un code natif généré par GCJ avec les optimisations activées (option -O3).
- Un code natif généré par le compilateur Excelsior JET.
Le mode interprété désactive le compilateur JIT et se rapproche donc des premières JVM qui n’en comportait pas (version antérieur à Java 1.2). Dans ce cas le bytecode est simplement interprété sans être compilé en code natif, ce qui induit forcément un temps d’exécution plus lent, sans pourvoir bénéficier des optimisations spécifiques de la machine (puisque le code n’est pas compilé à l’exécution).
Le mode Client correspond à la JVM par défaut pour les postes clients. Elle correspond à un compromis entre optimisation et temps de compilation en privilégiant une compilation rapide à une optimisation à outrance (c’est à dire sans activer toutes les optimisations). La JVM peut éventuellement recompiler certaine partie du code en activant plus d’optimisation lorsque ce dernier est souvent exécuté.
Le mode Server active beaucoup plus d’optimisation dès le départ, et peut donc se relever plus longue à démarrer pour de grosse application. Comme son nom l’indique elle est à privilégié pour les applications serveurs qui nécessitent de bonnes performances…
Pour cela, j’ai donc utilisé les outils suivant :
- Le JDK 5.0 update 6 de Sun.
- Le JDK 6.0 build 62 (en développement).
- Le Compilateur GCJ 3.4.4 du projet GNU.
- Excelsior JET 4.0 (version d’évaluation 30 jours).
Compilation & Exécution
Le bytecode Java est compilé avec le JDK 5.0, sans option particulière :
javac TestX.java
La différence entre les différentes JVM Client et Server se fait à l’exécution avec les paramètres -Xint (pour désactiver JIT) et -server (pour forcer la JVM Server). Le même bytecode est utilisé pour Java 6.0 (il n’y a pas de recompilation).
Note : Sous Windows la JVM Server n’est disponible que dans le JDK.
Le code natif de GCJ est généré avec la commande suivante :
gcj --main=TestX TestX.java -o TestX
Et avec l’option -O3 pour activer les optimisations standard :
gcj -O3 --main=TestX TestX.java -o TestX
Note : N’étant pas habitué aux paramètres de GCC/GCJ, je reste à votre écoute pour toutes remarques sur le sujet…
Enfin le code natif de JET est généré par la commande suivante (le compilateur JIT nécessite le bytecode généré par le compilateur javac) :
jc -main=TestX TestX.class
Pour chaque test, j’ai reporté les résultats suivant :
- Min : Le temps d’exécution minimum.
- Moyen : Le temps d’exécution moyen.
- Max : Le temps d’exécution maximum.
- Total : Le temps d’exécution total de la première exécution (afin de prendre en compte le temps de chargement de la JVM et de l’application natif).
- Chargement : La différence entre le temps Total et le temps Moyen.
Toutefois, le temps d’exécution total et de chargement de la JVM ne semble pas vraiment significatif. Certaine optimisation du système d’exploitation pouvant fortement fausser ce résultat, il est préférable de ne pas les prendre en compte…
Premier test : Garbage Collector (Allocation mémoire)
Ce premier test consiste simplement à créer un grand nombre d’objet temporaire dans une boucle, afin de faire travailler le Garbage Collector qui devra s’occuper d’allouer/désallouer la mémoire.
for (int i=0; i<max; i++) {
integer = new Integer(i);
}
Fichier source complet : Test1.java
Résultat :
Application | Min | Moyen | Max | Total | Chargement |
Java Interpreter | 36,750 | 37,147 | 37,422 | 37,719 | 0,572 |
Java 5.0 Client | 1,484 | 1,519 | 1,563 | 2,281 | 0.762 |
Java 5.0 Server | 1,265 | 1,296 | 1,344 | 1,890 | 0.594 |
Java 6.0 Client | 1,375 | 1,418 | 1,438 | 2.968 | 1,550 |
Java 6.0 Server | 1,218 | 1,247 | 1,281 | 1,938 | 0,691 |
GCJ 3.4.4 | 20,500 | 20,800 | 21,047 | 20.953 | 0,153 |
GCJ 3.4.4 -O3 | 18,937 | 19,612 | 20,110 | 20,406 | 0.794 |
JET 4.0 | 11,938 | 12,356 | 12,563 | 13,203 | 0,847 |
Sans surprise, le mode interprété est de loin le plus long, mais les modes Client et Server sont bien plus performant que le code natif.
La raison est toute simple : La JVM utilise un espace mémoire propre qu’elle gère elle-même. Ainsi elle ne fait pas réellement d’allocation/désallocation mémoire à chaque itération, ce qui permet d’éviter un grand nombre d’appel système très coûteux…
Second test : Opération arithmétique
On se contentera ici d’un simple calcul :
for (int i=0; i<max; i++) {
value += (i/2L);
}
Fichier source complet : Test2.java
Résultat :
Application | Min | Moyen | Max | Total | Chargement |
Java Interpreter | 11,250 | 11,543 | 11,828 | 12,141 | 0,598 |
Java 5.0 Client | 8,328 | 8,594 | 8,782 | 8,984 | 0,390 |
Java 5.0 Server | 0,828 | 0,843 | 0,849 | 1,343 | 0,500 |
Java 6.0 Client | 7,625 | 7,818 | 8,016 | 8,937 | 1,119 |
Java 6.0 Server | 0,828 | 0,950 | 1,000 | 1,547 | 0,597 |
GCJ 3.4.4 | 5,875 | 6,165 | 6,437 | 6,750 | 0,585 |
GCJ 3.4.4 -O3 | 5,531 | 5,668 | 5,860 | 6,187 | 0,519 |
JET 4.0 | 1,093 | 1,121 | 1,156 | 1,547 | 0,426 |
Ici les résultats sont plus mitigés. On remarque déjà un gros écart entre le mode Server et le mode Client de la JVM, tout simplement parce que la JVM Server effectue toutes les optimisations possibles en fonction des caractéristiques de la machine.
Les résultats du code natif viennent s’intercaler entre les résultats de la JVM Server et de la JVM Client, et le code généré par JET se distingue en s’approchant du résultat obtenu par la JVM Server…
Troisième test : Les accésseurs/mutateurs
Il s’agit ici de tester l’utilisation d’un concept de base de la POO : les méthodes accésseurs et mutateurs.
for (int i=0; i<max; i++) {
object.setValue( i + object.getValue() );
}
Fichier source complet : Test3.java
Résultat :
Application | Min | Moyen | Max | Total | Chargement |
Java Interpreter | 20,750 | 21,078 | 21,313 | 21516 | 0,478 |
Java 5.0 Client | 0,453 | 0,465 | 0,484 | 0,829 | 0,364 |
Java 5.0 Server | 0.094 | 0,109 | 0,125 | 0.390 | 0,281 |
Java 6.0 Client | 0,250 | 0,259 | 0,266 | 0,437 | 0,178 |
Java 6.0 Server | 0,140 | 0,150 | 0,157 | 0,891 | 0,741 |
GCJ 3.4.4 | 2,265 | 2,359 | 2,422 | 3,188 | 0,766 |
GCJ 3.4.4 -O3 | 1,875 | 1,928 | 1,969 | 3,000 | 1,072 |
JET 4.0 | 0,109 | 0,115 | 0,141 | 0,766 | 0,651 |
Si l’interpréteur est loin derrière avec plus de 20 secondes, les JVM offrent encore les meilleurs performances que seul le code natif du compilateur JET arrive à atteindre.
Pour expliquer cela je vais faire un petit écart en parlant du C++ qui comporte un mot clef qui permet d’optimiser certain appel de méthode : inline. En effet lors de l’utilisation de méthode comportant peu d’instruction (typiquement un mutateur/accésseur), il est possible que le coût de l’appel à la méthode (qui implique aussi une vérification des paramètres et du type de retour) soit plus coûteux en temps que l’exécution du jeu d’instruction qu’elle contient. Le mot-clef inline du C++ permet d’indiquer au compilateur que le code complet de la méthode doit être inséré à la place de l’appel de la méthode, ce qui permet de se passer des appels de méthodes (Pour plus d’information sur le sujet, je vous invite à consulter la section concernant les fonctions inline dans la FAQ C++).
En Java il n’y a pas vraiment d’équivalent car cette tâche n’est pas du ressort du développeur mais du compilateur JIT, qui prend en compte un certain nombre d’élément en considération afin de déterminer s’il doit gérer la méthode en tant que méthode inline ou pas.
Dans ce cas la méthode ne devrait pas être ‘inline‘ puisque cela risquerait de « casser » la possibilité de redéfinir les méthodes dans une classe fille. Mais c’est pourtant ce que fait la JVM. Etant donné qu’elle s’occupe également du chargement des différentes classes, elle peut déterminer qu’il n’y a aucune classe fille chargé en mémoire, et donc que le code de ces méthodes peut être ‘inline‘. Si jamais une classe fille était chargé par la suite, il suffirait à la JVM de recompiler le code en question.
Le mauvais résultat en mode interprété peut aussi s’expliquer par le fait que la gestion des méthodes ‘inline‘ a été déplacé du compilateur javac vers le compilateur JIT (donc l’interpréteur n’effectue pas cette optimisation).
Etant donné les résultats obtenus, il semblerait que le code généré par JET dispose d’un système similaire. Par contre cela ne semble pas être le cas de GCJ.
Pour vérifier cela, le même test a été rejoué en utilisant le modificateur final sur ces méthodes afin d’empêcher toutes classes filles potentielles de les redéfinir. Cela permettra ainsi de forcer l’optimisation dans tous les cas.
Fichier source complet : Test3b.java
Résultat :
Application | Min | Moyen | Max | Total | Chargement |
Java Interpreter | 18,969 | 19,253 | 19485 | 19,781 | 0,528 |
Java 5.0 Client | 0,469 | 0,491 | 0,516 | 0,703 | 0,212 |
Java 5.0 Server | 0,093 | 0,103 | 0,110 | 0,563 | 0,460 |
Java 6.0 Client | 0,250 | 0,256 | 0,266 | 1,422 | 1,166 |
Java 6.0 Server | 0,125 | 0,150 | 0,172 | 0,640 | 0,490 |
GCJ 3.4.4 | 1,765 | 1,774 | 1,782 | 2,094 | 0,320 |
GCJ 3.4.4 -O3 | 0,328 | 0,331 | 0,344 | 0,703 | 0,372 |
JET 4.0 | 0,093 | 109,2 | 0,125 | 1,359 | 1,250 |
L’utilisation de méthodes final permet au code compilé avec GCJ de gagner une seconde et demi avec l’optimisation, alors qu’il n’y a pas vraiment de changement perceptible dans les autres cas puisque l’optimisation était déjà effectuée.
Les différences de temps obtenu sur le code GCJ sans optimisation et le bytecode interprété est du au fait que les appels de méthodes final sont plus performante même si elle ne sont pas ‘inline‘ puisqu’il n’y a pas de mécanisme de recherche d’une version surchargée de la méthode.
Justement je finirais par tester cela en ajoutant une classe fille qui surcharge ces deux méthodes, le tout sera utilisé avec le code suivant afin d’utiliser les deux classes conjointement et donc d’empêcher les méthodes ‘inline‘. Cela permet de déterminer le coût de la recherche des redéfinitions de méthode :
SimpleObject[] object = new SimpleObject[2];
object[0] = new SimpleObject();
object[1] = new ExtendedSimpleObject();
for (int i=0; i<max; i++) {
SimpleObject tmp = object[i%2];
tmp.setValue( i + tmp.getValue() );
}
Fichier source complet : Test3c.java
Résultat :
Application | Min | Moyen | Max | Total | Chargement |
Java Interpreter | 36,907 | 37,271 | 37,734 | 37,703 | 0,432 |
Java 5.0 Client | 2,812 | 2,840 | 2,906 | 3,094 | 0,254 |
Java 5.0 Server | 2,484 | 2,550 | 2,609 | 3,109 | 0,559 |
Java 6.0 Client | 5,469 | 5,546 | 5,640 | 6,906 | 1,360 |
Java 6.0 Server | 0,735 | 0,753 | 0,766 | 1,312 | 0,559 |
GCJ 3.4.4 | 7,719 | 8,084 | 8,390 | 8,922 | 0,838 |
GCJ 3.4.4 -O3 | 6,485 | 6,653 | 6,813 | 6,812 | 0,159 |
JET 4.0 | 1,547 | 1,581 | 1,610 | 2,187 | 0,606 |
Chose troublante : Si la JVM 5.0 s’en sort assez bien que ce soit en mode Client ou Server, la JVM 6.0 nous donne des résultats plus étrange. Si la version Server est trois fois plus rapide que son homologue 5.0, la version Client est presque deux fois plus lente…
Les autres résultats sont assez cohérents avec ceux déjà obtenu, avec de bon résultat de JET et la lenteur catastrophique du mode interprété !!!
Conclusion
Le code natif ne se révèle pas forcément plus rapide que le bytecode Java puisqu’il ne peut pas bénéficier de toutes les optimisations possibles du matériel à moins de procéder à une compilation spécifique. Il peut toutefois être plus performant que la JVM Client dans certain cas, reste à savoir si le gain de performance est vraiment aussi important sur une application cliente…
Dans bien des cas la JVM Server se montre la plus performante puisqu’elle profite au maximum des spécifités de la machine. Toutefois pour des applications normales (c’est à dire qui ne se contentent pas d’effectuer la même opération 100 millions de fois), le gain ne sera pas forcément aussi évident…
Concernant le mode interprété, il semble plus qu’évident qu’il est à l’origine du mythe sur la lenteur de Java, mais cela n’a plus rien à voir avec les JVM actuelles…
18 Commentaires + Ajouter un commentaire
Tutoriels
Discussions
- Recuperation du nom des parametres
- Difference de performances Unix/Windows d'un programme?
- Possibilité d'accéder au type générique en runtime
- L'apparition du mot-clé const est-il prévu dans une version à venir du JDK?
- Classes, méthodes private
- Définition exacte de @Override
- [ fuite ] memoire
- jre 1.5, tomcat 6.0 et multi processeurs
- [REFLEXION] Connaitre toutes les classes qui implémentent une interface
Bravo pour le travail, je rejoins les autres qui disent qu’il faut que tu en fasses un article.
yes, je sais, mais je n’ai aps le portable sous la main. Juste qu’il s’agit d’une version 1.4.2 mais je ne peux pas être plus précis. ( je ne me rappelle pas le numéro de version précis avec l’_ )
> MACOS : Le portable a été acheté il y a 3 mois, il s’agit donc d’une version récente.
C’est la version de Java que je voulais connaitre
Maintenant je t’avouerais que je ne connais pas du tout le fonctionnement de la JVM d’Apple…
MACOS : Le portable a été acheté il y a 3 mois, il s’agit donc d’une version récente. C’est vrai aussi que la machine me semble moins performante que l’équivalent PC.
Par ailleurs, lorsque j’ai utilisé Java2D, j’ai relevé quelques bugs d’affichage en activant l’anti-aliasing, la priorité à la qualité d’affichage etc. : l’application a planté violemment…
@trimok
>> 1) J’aurais aimé voir la comparaison avec du code C++ standard compilé
Le problème avec ces « comparaison » c’est que si Java et C++ sont très proche syntaxiquement, leurs approches de la POO est assez différente, et le même code n’implique pas forcément les mêmes méchanismes… mais je pourrais essayer… (est-ce pour toi que GCC est un compilateur correct ?)
>> 2) J’aurais aimé avoir aussi les tests avec les « anciennes » versions des JVM (1.3, 1.4)
Pourquoi pas…
@hedes
>> Sous MacOs, une application java/Swing est assez lente et semble utiliser plus de mémoire.
Je ne connais pas du tout le fonctionnement de Java sous MacOs…
Toutefois il y a eu pas mal d’amélioration sur ce point dans les dernières JVM (tu utilisais quelle version ?)…
Au passage si vous avez des idées de tests supplémentaires n’hésiter pas…
a++
Pour utiliser java sur MacOs et windows avec JET pour ce dernier, JET est extrèement plus rapide lors de l’utilisation de Swing : initialisation, affichage de fenêtres, raffrachissement etc..
Effectivement, lorsqu’on fait du calcul algorithmique pur ( calcul cartographique par exemple ou traitement d’images ), la différence n’est pas flagrante.
Sous MacOs, une application java/Swing est assez lente et semble utiliser plus de mémoire.
Utilisation de la mémoire : il faut faire très attention lors du codage et anticiper les problèmes à venir. Dans notre cas, nous avons été obligé de recoder certaines classes ( BufferedImage par exemple ) pour éviter que l’appli ne s’arrête au bout de quelques secondes et d’implémenter un pool de buffers.
Autre exemple : on a supprimé les GridBagLayout car on s’est rendu compte qu’ils consommaient de la mémoire.
Effectivement, nous sommes allés contre la parole officielle qui dit de ne pas s’occuper de la mémoire mais cela marche beaucoup mieux
A+
Excellent travail d’adiGuba, merci.
Mais:
Je fais peut-être partie des « balourds » que cite Pill_S…
Dans une « autre vie », après avoir travaillé en Pascal Objet/Delphi/C/C++, j’ai fait (2004) des projets WEB/Java Serveur (serveur d’application Websphere 5 sur UNIX, JVM 1.3), et j’ai rencontré des gros problèmes de rapidité, dans des applications « gourmandes » (beaucoup de traitements, beaucoup de mémoire utilisée).
Plus loin dans le temps (2000), j’avais également rencontré des ingénieurs d’Ilog me disant que leur(s) produit(s)de type système expert – en mode Java, était 2 à 3 fois plus lent qu’en mode C++
Donc :
1) J’aurais aimé voir la comparaison avec du code C++ standard compilé
(pas de librairie MFC ou de framework . NET), avec un compilateur correct.
2) J’aurais aimé avoir aussi les tests avec les « anciennes » versions des JVM (1.3, 1.4)
Très bon article !!
On pourra enfin rediriger quelque part les balourds qui jurent que Java c’est lent
Très intérressant. J’essayerais d’en prendre compte pour l’article
Merci
A propos de cette option de gcc, en fait elle ne brise pas la compatibilité avec les autres proc. Elle permet seulement d' »embarquer » dans l’exécutable du code optimisé pour les P4. Maintenant, il existe aussi l’option march (à la place de mcpu) qui elle brise la compatibilité avec les autres procs, mais optimise à fond l’exécutable.
Dans le mesure ou la JVM fonctionne sur tous les cpu, je crois que la comparer avec l’option mpcu de gcc est équitable.
Il existe aussi d’autres possibilités comme la possibilité de profiler son code, c’est à dire de créer un permier exécutable qui va collecter des infos concernant son exécution puis de recompiler son appli en vue d’en créer un deuxième qui va tenir compte des infos collectées par le premier. (Un peu comme la jvm le fait dans les tests 2 et 3). Le problème c’est que cela est un peu plus fastidieux et plus compliqué à faire. (Ps: Ne me demande pas quelles options utiliser, je ne les connais pas par coeur mais je sais que cela peut donner des résultats intéressants).
Enfin, concernant le deuxième test, je crois que l’on pourrait optimiser le compilateur en le forçant à allouer plus de mémoire que nécessaire. De même, en c++ il est possible d’utiliser des allocateurs pour éviter ce jeu d’allocation/desallocation permanente de mémoire. Je ne pense pas qu’il existe un équivalent en java (mais en même tps, ce langage n’est pas prévu pour être éxécuter directement, donc c’est normal).
Mais si déjà tu pouvais faire le test en utlisant l’option mcpu, ce serait super.
>> Au niveau de la méthodologie par exemple, je crois qu’il aurai été intéressant d’ajouter des options liées à l’architecture du processeur pour les exécutables créés avec gcj, ce en ajoutant une option comme mpcu=pentium4 par exemple.
Je suis d’accord avec toi… mais comme je l’ai indiqué je ne connais pas suffisament les options d’optimisations de GCC…
Je pense que je vais en faire un article en détaillant un peu plus les optimisations de la JVM, et j’en profiterais pour rajouter des tests avec cette option de GCC… et je reste à votre écoute pour toutes suggestions…
Mais ce qui est intérressant c’est que la JVM peut utiliser ce type d’optimisation sans casser la portabilité du programme… alors qu’avec du code natif il faudrait générer un exécutable par OS et par architecture…
a++
Pas mal, mais je ne suis pas d’accord sur tout.
Au niveau de la méthodologie par exemple, je crois qu’il aurai été intéressant d’ajouter des options liées à l’architecture du processeur pour les exécutables créés avec gcj, ce en ajoutant une option comme mpcu=pentium4 par exemple.
Excellentissime !
Bravo, beau travail.
et puis… article++
pour GCJ, je ne crois pas que l’on puisse utiliser rt.jar (a cause de la licence de celui-ci). Ce que je voulais dire, c’est qu’on ne peut pas trop le comparer, puisque le code qui est derriere n’est pas le même que celui de sun.
>> tu aurais pu en faire un article non ? ;))
Au début je voulais juste faire un tout petit tests…
>> Qui nous fera un jours un article sur le code managé, en éssayant d’éviter de retomber dans les travers JAVA VS .NET
Justement j’avais déjà lu un article similaire concernant dotNET qui disait un peu la même chose, et que le code natif était souvent moins performant que le code managé…
Malheureusement je ne le retrouve plus…
>> Pour la compilation avec GCJ, tu as utilisé Gnu-classpath ou les JFC de sun ?
J’ai fais les tests avec GCC 3.4.4, GCJ 3.4.4 et la libraire libiconv (apparemment neccessaire pour l’UTF8). Je pense donc qu’il doit s’agir de Gnu-classpath…
Mais si tu as plus d’info sur les JFC et comment les utiliser je pourrais tester la différence…
a++
Pour la compilation avec GCJ, tu as utilisé Gnu-classpath ou les JFC de sun ? parce que si c’est le premier cas, ca peut influer sur le resultat j’imagine.
+1 pour l’article
Trés interessant. Qui nous fera un jours un article sur le code managé, en éssayant d’éviter de retomber dans les travers JAVA VS .NET
Très intéressant
et formidable travail !!!
tu aurais pu en faire un article non ? ;))