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; } }
@Param
permet de paramétrer le test. JMH va réaliser une exécution de benchmark par valeur de paramètre (ici 3 fois).@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.@Benchmark
définit une méthode de benchmark. On a donc ici 2 benchmarks différents dans la même classe de benchmark- 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. - 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 :
- Le nom du microbenchmark (le nom de la méthode préfixée par le nom de la classe).
- La valeur du paramètre.
- Le mode de mesure : ici
avgt
pour average time : le temps moyen d’exécution. - Le nombre d’itérations de mesure.
- Le résultat : ici en nanoseconde par opération.
- 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 OSperf
.-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 😉