Benchmark java : introduction à JMH
“À 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 2 implémentations différentes d’un algorithme, ou encore estimer les gains de performance apportés par la dernière JVM… Je vous propose de découvrir cet outil et d’écrire votre premier benchmark JMH en 10 minutes chono !
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é !
public class MyBenchmark {
public static void main(String[] args) {
// start stopwatch
long startTime = System.nanoTime();
// Here is the code to measure
double log42 = Math.log(42);
// stop stopwatch
long endTime = System.nanoTime();
System.out.println("log(42) is computed in : " + (endTime - startTime) + " ns");
}
}
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 10000è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 ?
The right tool for the right job
Heureusement, il existe des outils apportant une solution aux problèmes ci-dessus ; JMH 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 JMH.
Déballage
Dans une boite de JMH, on trouve :
- un générateur de projet de benchmark JMH (l’archetype Mavenjmh-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)
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 Maven.
Création d’un projet JMH
Pour générer le projet de benchmark, utilisez l’archetype jmh-java-benchmark-archetype :
$ mvn archetype:generate \
-DinteractiveMode=false \
-DarchetypeGroupId=org.openjdk.jmh \
-DarchetypeArtifactId=jmh-java-benchmark-archetype \
-DarchetypeVersion=1.5.2 \
-DgroupId=fr.soat \
-DartifactId=jmh-sample-benchmak \
-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.
Écriture du benchmark
L’archetype a par ailleurs généré un squelette de classe de benchmark, appelée MyBenchmark, dans laquelle on retrouve une méthode testMethod(),annotée par un @Benchmark indiquant à JMH où se trouve le code à benchmarker :
public class MyBenchmark {
@Benchmark
public void testMethod() {
// This is a demo/sample template for building your JMH benchmarks. Edit as needed.
// Put your benchmark code here.</h4>
}
}
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 :
public class MyBenchmark {
@Benchmark
public double benchmark_logarithm_jdk() {
return java.lang.Math.log(42);
}
@Benchmark
public double benchmark_logarithm_apache_common() {
return org.apache.commons.math3.util.FastMath.log(42);
}
@Benchmark
public double benchmark_logarithm_jafama() {
return odk.lang.FastMath.log(42);
}
@Benchmark
public double benchmark_logarithm_jafama_logQuick() {
return odk.lang.FastMath.logQuick(42);
}
}
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 :
jmh-sample-benchmark$ mvn clean package
Execution du benchmark
A présent, nous allons exécuter le jar pour démarrer le benchmark :
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 au final 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 !
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.