Tutoriel pour mesurer les performances d'un code Java avec JMH

| Bruno Doolaeghe | Soat |

« À votre avis : c'est qui le plus fort, l'hippopotame ou l'éléphant ? » Voilà une question à laquelle il sera difficile de répondre, même avec un outil tel que JMH de l'OpenJDK ! Néanmoins, il pourra vous être très utile pour mesurer les performances d'un code Java, comparer deux implémentations différentes d'un algorithme, ou encore estimer les gains de performances apportés par la dernière JVM… Je vous propose de découvrir cet outil et d'écrire votre premier benchmark JMH en dix minutes chrono !

Pour réagir à ce tutoriel, un espace de dialogue vous est proposé sur le forum Commentez .

- Titre : Tutoriel pour mesurer les performances d'un code Java avec JMH

- Auteurs : Bruno Doolaeghe, Soat

- Parution : 14 août 2015

- Licence : Copyright ® 2015 Bruno Doolaeghe (Soat). Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Le chrono et la JVM

- II. The right tool for the right job

- II-A. Déballage

- II-B. Installation de JMH

- III. Création d'un projet JMH

- III-A. Écriture du BENCHMARK

- III-B. Build du projet

- III-C. Exécution du BENCHMARK

- IV. Conclusion

- V. Remerciements

I - Le chrono et la JVM

Comment mesurer la performance d'un bout de code ? Le microbenchmarking, puisque c'est de cela qu'il s'agit, est une tâche en apparence simple. Prenons, par exemple, la fonction mathématique “logarithme” : mesurer le temps de calcul du logarithme d'un flottant en Java ne semble pas très compliqué ; un simple “chronométrage” en utilisant la date système dans un main(), et le tour est joué !

1. public class MyBenchmark {

3. public static void main(String[] args) {

4. // start stopwatch

5. long startTime = System.nanoTime();

6. // Here is the code to measure

7. double log42 = Math.log(42);

8. // stop stopwatch

9. long endTime = System.nanoTime();

10. System.out.println("log(42) is computed in : " + (endTime - startTime) + " ns");

11. }

13. }

Malheureusement pour nous, les choses ne sont pas aussi simples. En effet, les temps que nous pouvons ainsi mesurer sont d'une part assez variables, et d'autre part pas forcement représentatifs de la réalité ; à cela, plusieurs raisons :

- la JVM n'exécute pas notre code tel qu'il est écrit : le JIT au runtime peut optimiser le code Java, réordonner les instructions, voire carrément supprimer des instructions inutiles (c'est typiquement ce qui arrive ici : la variable log42 étant inutilisée !) ;

- par ailleurs, elle n'exécute pas un même code de façon déterministe à chaque exécution : le JIT peut en effet décider de compiler à la volée le bytecode en code natif, au lieu de l'interpréter (par défaut, au 10 000ème appel) ;

- enfin, la charge physique de la machine (CPU, mémoire, autres process…) au moment du run peut varier dans le temps selon son utilisation globale, et ralentir le programme. Se baser sur une seule mesure et espérer un résultat représentatif est donc illusoire…

Mais alors, comment mesurer les performances de notre code ?

II - The right tool for the right job

Heureusement, il existe des outils apportant une solution aux problèmes ci-dessus ; JMHJMH en est un. Il s'agit d'un outil libre, léger, et plutôt simple à prendre en main. Voici donc, en quelques mots, les étapes qu'il vous faudra suivre pour écrire votre premier benchmark.

II-A - Déballage

Dans une boite de JMH, on trouve :

- un générateur de projet de benchmark JMH (l'archetype Maven jmh-java-benchmark-archetype) ;

- une collection d'annotations, pour configurer votre benchmark ;

- un Runner, pour exécuter votre benchmark ;

- une notice d'utilisation (une javadoc et des exemples de code).

II-B - Installation de JMH

Il est recommandé d'utiliser JMH avec Maven, pour créer un projet de benchmark. On écrira dans ce projet le code à benchmarker ; une autre façon de faire consiste simplement à référencer le JAR du code à benchmarker par dépendance .

III - Création d'un projet JMH

Pour générer le projet de benchmark, utilisez l'archetype jmh-java-benchmark-archetype :

1. $ mvn archetype:generate \

2. -DinteractiveMode=false \

3. -DarchetypeGroupId=org.openjdk.jmh \

4. -DarchetypeArtifactId=jmh-java-benchmark-archetype \

5. -DarchetypeVersion=1.5.2 \

6. -DgroupId=fr.soat \

7. -DartifactId=jmh-sample-benchmak \

8. -Dversion=1.0-SNAPSHOT

L'archetype génère un projet JAR appelé jmh-sample-benchmak, contenant un pom.xml, déclarant les dépendances vers les JARs de JMH et les plugins nécessaires au build.

III-A - Écriture du BENCHMARK

L'archetype a par ailleurs généré un squelette de classe de benchmark, appelé MyBenchmark, dans lequel on retrouve une méthode testMethod(),annotée par un @Benchmark indiquant à JMH où se trouve le code à benchmarker :

1. public class MyBenchmark {

3. @Benchmark

4. public void testMethod() {

5. // This is a demo/sample template for building your JMH benchmarks. Edit as needed.

6. // Put your benchmark code here.</h4>

7. }

9. }

On y écrira une ou plusieurs méthodes ainsi annotées, à la manière d'une classe de test JUnit ; chacune d'entre elles sera benchmarkée par JMH. On pourra ainsi comparer différents codes. Je prendrai comme exemple le calcul du logarithme, en utilisant différentes librairies :

- java.lang.Math.log() du JDK8 ;

- org.apache.commons.math3.util.FastMath.log() d'Apache commons Maths ;

- odk.lang.FastMath.log() de javafama ;

- odk.lang.FastMath.logQuick() de javafama également.

On obtient ainsi le code du benchmark suivant :

1. public class MyBenchmark {

3. @Benchmark

4. public double benchmark_logarithm_jdk() {

5. return java.lang.Math.log(42);

6. }

8. @Benchmark

9. public double benchmark_logarithm_apache_common() {

10. return org.apache.commons.math3.util.FastMath.log(42);

11. }

13. @Benchmark

14. public double benchmark_logarithm_jafama() {

15. return odk.lang.FastMath.log(42);

16. }

18. @Benchmark

19. public double benchmark_logarithm_jafama_logQuick() {

20. return odk.lang.FastMath.logQuick(42);

21. }

23. }

III-B - Build du projet

Avant de lancer le benchmark, il faut bien sûr faire un build Maven du projet, pour générer du code technique, l'assembler au Runner JMH, et empaqueter le tout dans un “uber” JAR benchmark.jar exécutable :

1. jmh-sample-benchmark$ mvn clean package

III-C - Exécution du BENCHMARK

À présent, nous allons exécuter le JAR pour démarrer le benchmark :

1. jmh-sample-benchmark$$ java -jar target/benchmark.jar

Voilà ! Le benchmark tourne. Les logs d'exécution s'affichent sur la sortie standard, et donnent finalement les résultats obtenus :

Benchmark

Mode

Cnt

Score

Error

Units

MyBenchmark.benchmark_logarithm_apache_common

thrpt

200

40,112

± 0,113

ops/us

MyBenchmark.benchmark_logarithm_jafama

thrpt

200

95,502

± 0,255

ops/us

MyBenchmark.benchmark_logarithm_jafama_logQuick

thrpt

200

142,486

± 0,604

ops/us

MyBenchmark.benchmark_logarithm_jdk

thrpt

200

341,494

± 3,196

ops/us

On obtient, par ligne, le résultat de chaque méthode testée. Le contenu des colonnes nous donne :

- le Mode de benchmark, désignant le type de mesures réalisées : ici thrpt (pour Troughput), c'est-à-dire un débit moyen d'opérations (opérations exécutées par unité de temps) ;

- Cnt (pour count) nous donne le nombre de mesures réalisées pour calculer notre score : ici, 200 mesures réalisées ;

- Score désigne la valeur du throughput moyen calculé ;

- Error représente la marge d'erreur de ce score ;

- Units est l'unité de mesure dans laquelle est affiché le score : ici opérations par microseconde.

Sur notre benchmark, java.lang.Math.log() du JDK8 obtient le meilleur résultat, avec une moyenne de 341,494 opérations par seconde !

IV - Conclusion

Nous venons de voir en quelques lignes les fonctionnalités de base de JMH, qui vous permettront de réaliser votre premier banc d'essai.

Ce benchmark du logarithme, qui illustre l'utilisation de JMH, n'est cependant pas très sérieux. On peut en effet en faire plusieurs critiques :

- Qu'est-ce qui nous permet de dire que les mesures faites sont représentatives ?

- Le débit “moyen” est-il un indicateur suffisant pour affirmer qu'il faut toujours utiliser le log() du JDK ? Est-il à chaque fois meilleur ?

- Il a été meilleur pour calculer log(42), mais reste-t-il le meilleur pour calculer log(42.5), log(0.0000001) ou log(10000000) ?

Dans un prochain article, nous verrons comment configurer plus finement JMH (“warm up”, cycles d'itérations…), quels autres indicateurs statistiques nous pouvons obtenir, et comment les interpréter pour en tirer des conclusions intéressantes.

V - Remerciements

Cet article a été publié avec l'aimable autorisation de la société Soat.

Nous tenons à remercier ced pour sa relecture attentive de cet article et milkoseck pour la mise au gabarit.