Introduction à JMH – Java Microbenchmark Harness

Introduction à JMH – Java Microbenchmark Harness

Dans mon précédent article For vs Stream, j’ai utilisé JMH – The Java Microbenchmark Harness, un outil pour réaliser des microbenchmarks de manière facile, et surtout, pertinente.

Cet article à pour but de vous présenter l’outil et son utilisation.

Mais tout d’abord : c’est quoi un microbenchmark ?

Microbenchmark

Benchmark ou banc d’essai en français : un programme qui permet de mesurer les performances d’un système, pour le comparer à d’autres.

Microbenchmark : un benchmark fait pour mesurer les performances d’une très petite, et très spécifique, portion de code.

Le problème d’un microbenchmark est que sa pertinence est fortement liée à son environnement d’exécution, en Java : la JVM.

En effet, celle-ci contient de nombreux composants (interpréteur de code, Garbage Collector – GC, Just In Time Compiler – JIT, …), et réalise de nombreuses optimisations au fil du temps, via le JIT principalement.

Si on veut qu’un microbenchmark soit pertinent, il faut que les composants de la JVM ne viennent pas interagir avec la manière dont on exécute et mesure le benchmark.

La mesure en elle-même est fort complexe, si ce qu’on veut mesurer est proche de la milliseconde, alors un simple passage du GC peut la doubler. De plus, mesurer le temps n’est pas facile; si vous voulez plus d’information sur la mesure du temps en Java, voici un excellent article : Measuring time from Java to kernel and back.

Donc, réaliser un microbenchmark via un simple Java main ou via un test JUnit n’est pas une bonne façon de faire. Comme utiliser System.currentTimeInMillis()!
Il nous faut un outil plus puissant, et qui sache s’interfacer avec les composants internes de la JVM.

Cet outil, c’est JMH – The Java Microbenchmark Harness, créé par Aleksey Shipilëv.

Ecrire un microbenchmark avec JMH

Pour générer un projet, le plus simple est d’utiliser l’archetype Maven fournit par JMH.

mvn archetype:generate \
          -DinteractiveMode=false \
          -DarchetypeGroupId=org.openjdk.jmh \
          -DarchetypeArtifactId=jmh-java-benchmark-archetype \
          -DgroupId=fr.loicmathieu.jmh \
          -DartifactId=jmh-benchmarks \
          -Dversion=1.0

Il génère une classe MyBenchmark comme suit :

import org.openjdk.jmh.annotations.Benchmark;

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

}

Dans un projet JMH, on peut héberger plusieurs groupes de benchmark, chacun dans sa propre classe.

Prenons un exemple un peu plus complet, tiré de mes tests For vs Stream :

public class ForVsStream {

    @Param({"10", "1000", "10000"}) // <1>
    int size;

    List list;

    @Setup  // <2>
    public void setup() {
        list = new ArrayList<>(size);
        for (int i = 0; i < size; i++) { list.add(i); } 
    } 

    @Benchmark // <3>
    public void testForLoop_doNothing(Blackhole bh) {  // <4>
        for (Integer i : list) {
            bh.consume(i);
        }
    }

    @Benchmark  // <5>
    public int testForLoop_Accumulation() {
        int acc = 0;
        for (Integer i : list) {
            acc += i;
        }
        return acc;
    }
}
  1. @Param permet de paramétrer le test. JMH va réaliser une exécution de benchmark par valeur de paramètre (ici 3 fois).
  2. @Setup permet d’exécuter du code, une fois avant chaque exécution de benchmark, pour initialiser le benchmark (ici initialiser la liste). C’est très important de n’inclure dans la méthode de benchmark que le code que l’on veut mesurer, et déporter dans une méthode annotée par @Setup tout le code d’initialisation, sinon les résultats seront faussés.
  3. @Benchmark définit une méthode de benchmark. On a donc ici 2 benchmarks différents dans la même classe de benchmark
  4. Une des principales optimisations du JIT est l’élimination de code mort (Dead Code Elimination – DCE), pour éviter cela, JMH fournit une classe Blackhole que l’on peut utiliser. Ici, si on n’avait rien fait dans le boucle for, le JIT aurait fini par éliminer celle-ci, et le benchmark aurait testé un appel de méthode vide.
  5. Dans ce cas-ci, comme la méthode retourne une valeur, on n’a pas besoin de Blackhole, retourner un objet suffit.

Exécuter le microbenchmark

Pour exécuter nos benchmarks, il y a deux solutions : programmatiquement via l’API de JMH, ou via un JAR de benchmark.

Pour l’exécuter programmatiquement il faut ajouter un main à votre classe de benchmark. On peut alors lancer le main depuis son IDE par exemple.

public static void main(String[] args) throws RunnerException {
    Options opt = new OptionsBuilder()
            .include(ForVsStream.class.getSimpleName())
            .build();

    new Runner(opt).run();
}

Bien que pratique, cette méthode n’est pas la plus recommandée, pour passer des paramètres au benchmark on doit les coder en dur.
Le lancement depuis un IDE est déconseillé par le créateur de JMH : il faut réaliser un package Maven avant l’appel de la méthode main pour initialiser l’infrastructure JMH, et certains IDEs pourraient ne pas le faire.

La méthode conseillée est le lancement via la ligne de commande Maven, après avoir créé l’artefact de benchmark via mvn clean package. L’artefact Maven créé contient vos benchmarks, ainsi que le code d’infrastructure de JMH. Il sera créé dans le répertoire target et aura comme nom benchmarks.jar.
On pourra ensuite le lancer de manière classique via la ligne de commande java en donnant en paramètre le nom du benchmark à exécuter : java -jar target/benchmarks.jar ForVsStream.

Par défaut, pour chaque méthode de benchmark (et chaque valeur de paramètre), JMH exécute 5 itérations de 10s de préchauffe (warmup) et 5 itérations de 10s de mesure.

Et voici un exemple de résultats produit :

ForVsStream.testForLoop_doNothing                  10  avgt    5      46,715 ±     4,581  ns/op
ForVsStream.testForLoop_doNothing                1000  avgt    5    4581,216 ±   355,653  ns/op
ForVsStream.testForLoop_doNothing               10000  avgt    5   45320,910 ±   670,446  ns/op
ForVsStream.testForLoop_Accumulation               10  avgt    5      12,960 ±     0,809  ns/op
ForVsStream.testForLoop_Accumulation             1000  avgt    5     749,318 ±    14,473  ns/op
ForVsStream.testForLoop_Accumulation            10000  avgt    5    6679,846 ±    81,442  ns/op

On a ici 6 colonnes :

  1. Le nom du microbenchmark (le nom de la méthode préfixée par le nom de la classe).
  2. La valeur du paramètre.
  3. Le mode de mesure : ici avgt pour average time : le temps moyen d’exécution.
  4. Le nombre d’itérations de mesure.
  5. Le résultat : ici en nanoseconde par opération.
  6. L’erreur statistique : si celle-ci est importante par rapport à vos résultats (20 – 30%), c’est qu’il y a un soucis.

On peut modifier la manière dont JMH réalise l’exécution du benchmark, et mesure les résultats, de plusieurs manières différentes : via des paramètres de ligne de commande, via l’OptionsBuilder si vous utilisez un main, ou via des annotations à placer sur votre classe de Benchmark ou sur une de vos méthodes.
C’est généralement cette option que je préfère, car la définition de l’exécution et de la mesure dépend généralement de ce que l’on veut mesurer, et n’a pas besoin de changer fréquemment (sinon, privilégiez la ligne de commande).

Si vous vous demandez quelles sont les options que l’on peut passer à la ligne de commande, il y a une aide intégrée via java -jar target/benchmarks.jar -h.

Voici les options que je passe le plus souvent à un benchmark, elles peuvent être définies au niveau de la classe ou par méthode de benchmark :

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3, time = 3, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS)
@Fork(1)
public class ForVsStream {
    // benchmark ...
}
  • @BenchmarkMode(Mode.AverageTime) : définit le mode de benchmark temps moyen.
  • @OutputTimeUnit(TimeUnit.NANOSECONDS) : définit l’unité de mesure.
  • @Warmup(iterations = 3, time = 3, timeUnit = TimeUnit.SECONDS) : définit qu’on réalisera 3 itérations de préchauffe de 3 secondes
  • @Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS) : définit qu’on réalisera 3 itérations de mesure de 3 secondes
  • @Fork(1) : combien de fork de VM. On peut aussi passer des options à la JVM via cette annotation. A chaque itération (warmup, measurement) la VM est forkée pour éviter que les optimisations de la JVM interfèrent. 1 fork est le défaut. 0 fork permet de désactiver le fork (déconseillé).

La plus importante des options est le mode de benchmark, il y a 4 modes différents, et celui-ci devrait toujours être spécifié car si on ne sait pas ce que l’on veut mesurer … alors pourquoi faire un benchmark ?

  • Mode.AverageTime : temps moyen pris par l’opération.
  • Mode.Throughput : nombre d’opérations par unité de mesure.
  • Mode.SampleTime : sample les opérations (donc ne les enregistre pas toutes) et en calcule les statistiques (y compris les percentiles).
  • Mode.SingleShotTime : enregistre uniquement le temps d’exécution de la première itération.

Les modes les plus classiques sont AverageTime et Throughput. On peut utiliser plusieurs modes sur un seul benchmark.

Plus d’informations à ce sujet dans le sample JMH suivant : JMHSample_02_BenchmarkModes.java.

Quand vous lancez un benchmark via la ligne de commande Java, chaque option JVM va être passée au moment de la réalisation du benchmark, même en cas de fork.
Vous pourrez le constater dans le log de JMH.

Utiliser le profiler de JMH

Faire des benchmarks c’est bien, mais comprendre les résultats c’est encore mieux :).

Et c’est là qu’entre en jeu le profiler de JMH. Ce n’est pas un profiler aussi complet qu’un profiler comme celui que vous trouverez dans VisualVM, votre IDE ou qu’un profiler du marché tel que yourkit ou async-profiler, mais il permet d’examiner certains aspects de performance de votre benchmark facilement.

Le profiler de JMH accepte un ensemble de modes de profiling (en fait l’option -prof permet de lancer un ensemble de profilers), qui dépend de votre OS et des logiciels installés sur votre machine. Pour en connaitre la liste, vous pouvez lancer la commande java -jar target/benchmarks.jar -lprof.

Voici son résultat sur mon système (perf est installé, ce qui offre plus de profiler):

Supported profilers:
          cl: Classloader profiling via standard MBeans 
        comp: JIT compiler profiling via standard MBeans 
          gc: GC profiling via standard MBeans 
       hs_cl: HotSpot (tm) classloader profiling via implementation-specific MBeans 
     hs_comp: HotSpot (tm) JIT compiler profiling via implementation-specific MBeans 
       hs_gc: HotSpot (tm) memory manager (GC) profiling via implementation-specific MBeans 
       hs_rt: HotSpot (tm) runtime profiling via implementation-specific MBeans 
      hs_thr: HotSpot (tm) threading subsystem via implementation-specific MBeans 
      pauses: Pauses profiler 
        perf: Linux perf Statistics 
     perfasm: Linux perf + PrintAssembly Profiler 
    perfnorm: Linux perf statistics, normalized by operation count 
  safepoints: Safepoints profiler 
       stack: Simple and naive Java stack profiler 

Unsupported profilers:
   dtraceasm:  
[sudo: dtrace : commande introuvable
]
    xperfasm:  
[Cannot run program "xperf": error=2, Aucun fichier ou dossier de ce type]

Parlons rapidement des profilers les plus classiques :

  • -prof gc : permet de profiler le garbage collector, et donc d’une manière très basique la mémoire. Il permet entre autre de connaitre l’allocation mémoire d’un benchmark.
  • -prof stack : permet de profiler les stack Java, et donc d’une manière très basique le CPU.
  • -prof perf : permet d’utiliser l’outil de statistique OS perf.
  • -prof perfasm : permet d’accéder au code assembleur généré dans les hotspot du code uniquement.

Exemple de résultat avec un profiler de type gc:

Benchmark                                                                   (size)  Mode  Cnt       Score      Error   Units
ForVsStream.testForLoop_Accumulation                                            10  avgt   25      13,820 ±    0,323   ns/op
ForVsStream.testForLoop_Accumulation:·gc.alloc.rate                             10  avgt   25      ≈ 10⁻⁴             MB/sec
ForVsStream.testForLoop_Accumulation:·gc.alloc.rate.norm                        10  avgt   25      ≈ 10⁻⁶               B/op
ForVsStream.testForLoop_Accumulation:·gc.count                                  10  avgt   25         ≈ 0             counts
ForVsStream.testForLoop_Accumulation                                          1000  avgt   25     654,724 ±   27,606   ns/op
ForVsStream.testForLoop_Accumulation:·gc.alloc.rate                           1000  avgt   25      ≈ 10⁻⁴             MB/sec
ForVsStream.testForLoop_Accumulation:·gc.alloc.rate.norm                      1000  avgt   25      ≈ 10⁻⁴               B/op
ForVsStream.testForLoop_Accumulation:·gc.count                                1000  avgt   25         ≈ 0             counts
ForVsStream.testForLoop_Accumulation                                         10000  avgt   25    6717,517 ±  642,994   ns/op
ForVsStream.testForLoop_Accumulation:·gc.alloc.rate                          10000  avgt   25      ≈ 10⁻⁴             MB/sec
ForVsStream.testForLoop_Accumulation:·gc.alloc.rate.norm                     10000  avgt   25       0,001 ±    0,001    B/op
ForVsStream.testForLoop_Accumulation:·gc.count                               10000  avgt   25         ≈ 0             counts
ForVsStream.testForLoop_doNothing                                               10  avgt   25      49,177 ±    1,296   ns/op
ForVsStream.testForLoop_doNothing:·gc.alloc.rate                                10  avgt   25      ≈ 10⁻⁴             MB/sec
ForVsStream.testForLoop_doNothing:·gc.alloc.rate.norm                           10  avgt   25      ≈ 10⁻⁵               B/op
ForVsStream.testForLoop_doNothing:·gc.count                                     10  avgt   25         ≈ 0             counts
ForVsStream.testForLoop_doNothing                                             1000  avgt   25    4843,801 ±   76,651   ns/op
ForVsStream.testForLoop_doNothing:·gc.alloc.rate                              1000  avgt   25      ≈ 10⁻⁴             MB/sec
ForVsStream.testForLoop_doNothing:·gc.alloc.rate.norm                         1000  avgt   25      ≈ 10⁻³               B/op
ForVsStream.testForLoop_doNothing:·gc.count                                   1000  avgt   25         ≈ 0             counts
ForVsStream.testForLoop_doNothing                                            10000  avgt   25   48239,677 ± 1162,210   ns/op
ForVsStream.testForLoop_doNothing:·gc.alloc.rate                             10000  avgt   25      ≈ 10⁻⁴             MB/sec
ForVsStream.testForLoop_doNothing:·gc.alloc.rate.norm                        10000  avgt   25       0,004 ±    0,001    B/op
ForVsStream.testForLoop_doNothing:·gc.count                                  10000  avgt   25         ≈ 0             counts

Exemple de résultat avec un profiler de type stack:

Result "fr.loicmathieu.jmh.ForVsStream.testStream_AccumulationByMap":
  88,852 ±(99.9%) 9,101 ns/op [Average]
  (min, avg, max) = (85,933, 88,852, 91,581), stdev = 2,364
  CI (99.9%): [79,751, 97,953] (assumes normal distribution)

Secondary result "fr.loicmathieu.jmh.ForVsStream.testStream_AccumulationByMap:·stack":
Stack profiler:

....[Thread state distributions]....................................................................
 50,0%         RUNNABLE
 50,0%         TIMED_WAITING

....[Thread state: RUNNABLE]........................................................................
 23,0%  46,0% java.util.stream.AbstractPipeline.wrapSink
 11,8%  23,5% java.util.stream.IntPipeline.reduce
 10,7%  21,3% java.util.ArrayList$ArrayListSpliterator.forEachRemaining
  4,3%   8,5% fr.loicmathieu.jmh.generated.ForVsStream_testStream_AccumulationByMap_jmhTest.testStream_AccumulationByMap_avgt_jmhStub
  0,1%   0,2% java.util.stream.ReduceOps.makeInt
  0,1%   0,1% java.util.ArrayList.spliterator
  0,1%   0,1% java.util.stream.ReferencePipeline.mapToInt
  0,0%   0,1% java.util.stream.StreamSupport.stream
  0,0%   0,0% java.util.stream.ReduceOps$6.makeSink

....[Thread state: TIMED_WAITING]...................................................................
 50,0% 100,0% java.lang.Object.wait

Utilisation avancée

  • @TearDown permet d’exécuter du code après l’exécution du benchmark. Cela peut servir à réaliser des vérifications (des assertions par exemple) pour être sûr que le code de votre benchmark est bon (de la même manière que chaque test JUnit doit comprendre des assertions JUnit).
  • @State permet de définir la portée de l’état de votre benchmark. Si vous utilisez des paramètres, des constantes, etc…; en utilisant @State sur la classe de votre benchmark (ou une de ses méthodes), vous pouvez définir si ces paramètres doivent être initialisés au benchmark ou au Thread. Vous pouvez aussi définir des classes de State que vous pouvez ensuite injecter dans vos benchmarks, plus d’information dans le sample JMHSample_03_States.javaJMHSample_03_States.java.

Voici un article en anglais qui introduit quelques autres concepts avancés : http://tutorials.jenkov.com/java-performance/jmh.html.

Conclusion

JMH est le meilleur outil pour écrire vos microbenchmarks en Java. Il est simple d’utilisation pour les cas les plus simples, mais il compte plein de fonctionnalités avancées qui vous permettent de contrôler le déroulement du benchmark et d’en comprendre les résultats.

Mes conseils pour l’écriture de vos benchmark :

  • Faites bien attention à l’élimination de code mort, et utilisez toujours une valeur de retour ou l’objet Blackhole.
  • Ne réalisez que des choses simples dans vos microbenchmarks, d’autres outils existent pour réaliser des benchmark qui ne sont pas micro (Gatling par exemple) ou pour tester le comportement multi-thread de votre application (JCStress).
  • Expérimentez de manière itérative.
  • Amusez-vous ;).

Pour aller plus loin, lisez la documentation qui se trouve dans les commentaires des samples, vous pouvez aussi relire mon article For vs Stream qui vous permet de mieux comprendre à quoi JMH peut servir.

Et le mot de la fin du mot de la fin est pour Aleksey Shipilëv, voici le warning qui est affiché en même temps que le rapport JMH :

REMEMBER: The numbers below are just data.
To gain reusable insights, you need to follow up on why the numbers are the way they are. Use profilers (see -prof, -lprof), design factorial experiments, perform baseline and negative tests that provide experimental control, make sure the benchmarking environment is safe on JVM/OS/HW level, ask for reviews from the domain experts.
Do not assume the numbers tell you what you want them to tell.

Un remerciement tout spécial à Logan pour sa relecture et la correction des nombreuses fautes d’orthographe 😉

Laisser un commentaire

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.