Accueil Nos publications Blog Benchmark java : introduction à JMH

Benchmark java : introduction à JMH

JMH duke“À 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

stopwatchComment 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

4193330368_ec4fe7fdc8_bHeureusement, 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

unpackDans une boite de JMH, on trouve :

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

typingL’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 :

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

soudeurAvant 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.