JMH : Comment mesurer la performance d’un algorithme dans le monde Java (article 2/2)

Voici, la suite du premier article sur les performances du code Java. Le premier article a permis de poser le contexte, mais également, de mettre en avant les mauvaises pratiques. Je dois avouer que c’était aussi mon cas avant de découvrir JMH. Nous allons justement détailler cet outil dans cet article.

 

JMH – THE benchmark

JMH est l’acronyme de Java Microbenchmarking Harness.

Il s’agit d’un outil développé en partie par les développeurs d’OpenJDK. Il a pour objectif de nous permettre de mesurer les performances d’un morceau de code pour estimer son temps d’exécution.

 

Le principe

JMH permet d’écrire correctement un benchmark pour du code devant s’exécuter au sein d’une JVM (donc pas uniquement du Java).
Son utilisation permet de s’appuyer sur des bonnes pratiques afin de réaliser les mesures de performance dans de bonnes conditions (au sens utilisation et exécution de code sous la JVM).
L’utilisation de JMH s’appuie principalement sur des annotations, que l’on insère dans notre code, et sur un outillage de construction de projets tel que Maven ou Gradle.

 

Comment cela se présente ?

Le plus simple pour réaliser notre premier benchmark JMH est de s’appuyer sur les archetypes (il s’agit de modèles de projets).

Dans la suite de cet article, nous utiliserons Maven comme outil de construction de projet. Si vous souhaitez réaliser ces exemples sur votre environnement, nous considérons que Java et Maven sont installés sur votre machine.

Revenons aux fameux archetypes. Avec la commande suivante, nous allons obtenir un projet Maven avec tout ce qui est nécessaire pour utiliser le benchmark JMH.

Cela génère un projet composé d’un fichier pom.xml (nécessaire pour Maven), ainsi que de la classe Java suivante.

L’annotation @Benchmark permet de spécifier qu’il s’agit d’une méthode pour laquelle JMH doit :

  • Générer le code du benchmark pour cette méthode lors de la compilation,
  • Ajouter cette méthode comme benchmark dans la liste des benchmarks,
  • S’appuyer sur la présence éventuelle d’autres annotations pour configurer ce benchmark,
  • Préparer l’environnement permettant l’exécution de ce benchmark.

Ensuite, il suffit de laisser Maven s’occuper de la compilation, puis de la génération du package avec la commande suivante :

Pour exécuter le benchmark, il suffit d’utiliser la commande suivante :

La console fournit les informations ci-dessous.

La dernière ligne de la sortie console présente le résultat de la mesure.

Le schéma ci-dessous résume les étapes précédentes.

 

De l’exécution de ce benchmark, il en ressort des mesures qu’il faut savoir interpréter.

 

Interprétation des mesures

Reprenons le résultat de l’exécution précédente et détaillons les informations fournies.

La suite de l’exécution donne les informations suivantes :

Sur la partie gauche des explications ci-dessus, nous faisons référence à la notion de « Fork ». Détaillons ce principe.

Fork de la JVM

Le « fork » est simplement le fait de créer un contexte isolé d’exécution de la JVM pour chaque cycle de mesure. Plus simplement, il s’agit d’une nouvelle instance de la JVM. Cette pratique permet de réaliser des cycles de mesure dans les mêmes conditions au niveau de l’état de la JVM et des optimisations de code sans être influencé par les précédentes exécutions.

Avec ce premier exemple, il est évident que l’on est loin de la mesure intrusive évoquée en début de cet article (l’exemple « Hello World »).

Concernant les résultats précédents, nous avons obtenu le nombre d’opérations par seconde. C’est-à-dire le nombre de fois en une seconde que notre méthode (celle avec l’annotation @Benchmark) a été exécutée. Il s’agit du mode Throughput.

Les modes de mesures

JMH propose les modes de mesure suivants :

  • Throughput
  • AverageTime
  • SampleTime
  • SingleShotTime
  • All

Mode Throughput

Ce mode permet de mesurer le nombre d’opérations en une unité de temps. Dans le cas précédent, il s’agit de la seconde.


Mode : AverageTime

Ce mode permet de mesurer le temps que met l’exécution de la méthode testée.

Le test précédent est modifié en lui affectant le mode en AverageTime. Dans notre cas, nous devons ajouter l’unité de temps pour l’affichage (ici @OutputTimeUnit(TimeUnit.NANOSECONDS)). Voici le résultat.


Avec ce mode de mesure, les 200 itérations de mesure ont toujours eu lieu. Au final, ce code à 99,9% de chance de s’exécuter en 1,595ns à 0 ,115ns près.

Mode : Sample Time

Ce mode permet de mesurer le temps que met l’exécution de la méthode testée. Mais à la différence du mode AverageTime, dans ce cas de figure nous avons les mesures en centiles (0, 50, 90, 95, 99, 99.9, 99.99, 99.999, 99.9999 et 100).


Mode : Single Shot Time

Ce mode mesure une unique exécution de la méthode ciblée. Ce dernier est surtout préconisé en phase de test car une seule mesure est faite par itération de mesure. De plus, il n’y a pas de phase de warmup.

Nous constatons que le temps d’exécution de la méthode est très loin des temps mesurés avec les autres modes. Ce qui s’explique par l’absence de phase de warmup, ainsi que par la mesure unique.

Mode : All

Ce mode effectue les mesures avec les 4 modes (Throughput, AverageTime, SampleTime, SingleShotTime).

Ces différents modes de mesures ne présentent pas tous les résultats de mesure. Alors comment choisir ?

Comme nous venons de le voir, il est possible de modifier le comportement du benchmark au travers d’annotations. En plus des annotations mises en avant précédemment, nous allons décrire succinctement quelques-unes des principales annotations.

Les annotations

@Benchmark

Indique que la méthode annotée de cette marque est une méthode dont les temps d’exécution doivent être mesurés

@BenchmarkMode

Permet de spécifier le mode de mesure (Throughtput, AverageTime, SampleTime, SimpleShotTime, All)

 @Fork

Permet de modifier les paramètres par défaut concernant les contextes isolés d’exécution de la JVM (appelé Fork). C’est par le biais de cette annotation qu’il est possible de préciser le nombre de cycles pendant la phase de warmup (attribut « warmups »), mais également pendant la phase de mesure. Attention, il ne faut pas confondre ces phases de warmup et de mesure avec le nombre d’itération de warmup et de mesure au sein d’un cycle. Au lieu de faire de long discours, voici un schéma qui explique ce concept.

Par défaut, il n’y a pas de cycle de warmup du benchmark (la partie en bleue)

@Measurement

Cette annotation permet d’agir sur les paramètres par défaut liés aux mesures. L’attribut « iterations » permet de spécifier le nombre d’itérations au cours de chaque période de mesure. Il est également possible d’influer sur le temps de chaque itération.

@Warmup

Permet de modifier les paramètres par défaut concernant les phases de « chauffe » de la JVM. De la même manière que pour l’annotation « @Measurement », on retrouve les mêmes attributs, mais cette fois appliqués à chaque période de « warmup ».

@State

Il est souvent nécessaire de préparer le terrain avant de procéder aux mesures, par exemple en peuplant une base de données. Cette phase de préparation ne doit probablement pas être prise en compte dans le résultat des mesures du benchmark. Pour ce faire, JMH met à disposition cette annotation @State qu’il faut apposer à une classe interne statique. L’ensemble des opérations effectué au sein de cette classe ne seront pas comptabilisées dans les mesures.

@Setup

Cette annotation permet de préciser qu’une méthode doit être exécuter soit au début de chaque cycle de Fork (valeur à fixer à Level.Trial pour le paramètre de l’annotation @Setup), soit avant chaque itération de mesure (Level.Iteration), soit avant chaque invocation (Level.Invocation) des méthodes faisant l’objet du benchmark (les méthodes avec l’annotation @Benchmark). Elle ne peut être utilisée qu’au sein de la classe interne annotée avec @State.

@TearDown

Cette annotation a la même utilité que l’annotation @Setup à l’exception près qu’il s’agit de méthodes devant s’exécuter à la fin du processus. Les scopes Level.Trial, Level.Iteration et Level.Invocation sont également disponibles.

 

Le cas particulier de @State

Revenons plus en détail sur ces trois dernières annotations (@State, @Setup et @TearDown).

Pour bien comprendre le fonctionnement qui se cache derrière l’annotation @State, nous allons reprendre l’exemple auto-généré présenté au début de ce deuxième article. Nous allons le modifier légèrement en déclarant deux variables à partir desquelles un calcul est fait au sein de la méthode dont nous souhaitons obtenir les performances.

Nous ne souhaitons pas que la déclaration de ces variables soit prise en compte dans les mesures (nous pouvons imaginer qu’au lieu de déclarer ces variables, nous souhaitons peupler une base de données afin de préparer le jeu de test). Pour ce faire, nous allons ajouter une classe interne statique à laquelle nous allons associer l’annotation @State.  Pour pouvoir utiliser les variables déclarées au sein de cette classe interne, ou bien pour faire appel aux méthodes contenues dans cette classe ; il faut ajouter en paramètre de la méthode à mesurer une instance de cette classe interne. Lors de l’exécution du benchmark, le temps nécessaire à l’exécution du code contenu dans cette classe ne sera pas comptabilisé dans les mesures.

Le code résultant de ces modifications est présenté ci-dessous.

L’exécution de ce benchmark fournit le résultat suivant.

Voici qui clôt les détails sur les fonctionnalités offertes par JMH.

 

Et l’exemple des voitures…

Précédemment, nous avons évoqué le code d’un benchmark permettant d’évaluer différentes manières d’implémenter une méthode de recherche au sein d’une liste des véhicules ayant une couleur particulière.
Dans ce benchmark, nous comparons les implémentations de :

  • For
  • ForEach
  • Iterator
  • Stream&Filter
  • ParallelStream

Pour ce faire, nous avons déclaré plusieurs méthodes, une pour chaque implémentation. Chacune de ces méthodes possède l’annotation @Benchmark afin de la rendre éligible aux mesures. Nous avons laissé les paramètres par défaut lors de l’exécution de ce benchmark. Cela se traduit par 10 Fork de JVM pour chaque méthode à tester. Dans chacun de ces forks, 20 itérations de Warmup et 20 itérations de mesure.

Voici le résultat obtenu.

Suite à la réalisation de ce benchmark, nous pouvons en conclure que notre cas de figure d’utilisation des ParrallelStream est nettement plus performant que les autres manières de procéder. De plus, cette mesure a la plus petite marge d’erreur. Nous pouvons également constater que les performances des méthodes implémentant le For, StreamFilter et Iterator sont très proches les unes des autres, mais sont tout de même beaucoup moins performantes que la méthode utilisant ParrallelStream.

Et si nous recommençons une deuxième, puis une troisième fois, les conclusions sont-elles toujours les mêmes ?

Le tableau ci-dessous présente les résultats de ces trois tirs.

Méthode Mode

Tir 1

Score ± marge d’erreur

ms/op

Tir 2

Score ± marge d’erreur

ms/op

Tir 3

Score ± marge d’erreur

ms/op

MyBenchmark.testForEach AverageTime 241,917 ± 16,467 252,003 ± 6,699 266,187 ± 13,031
MyBenchmark.testParallelStream AverageTime 30,818 ±  1,351 28,367 ± 0,704 31,705 ±  0,677
MyBenchmark.testStreamFilter AverageTime 218,569 ±  7,174 245,105 ± 6,743 245,128 ± 11,691
MyBenchmark.testWithForEach AverageTime 232,698 ±  5,093 250,748 ± 7,497 265,843 ± 13,872
MyBenchmark.testWithIterator AverageTime 240,624 ±  4,219 250,732 ± 5,636 256,970 ± 13,224

Ce qui confirme les conclusions du premier tir.

 

Les enseignements de ces articles

Au cours de ces articles, nous avons mis en avant que pour obtenir les temps d’exécution d’un bout de code, il ne faut pas se contenter d’une unique mesure. De plus, il est important d’être le moins intrusif possible afin de conserver le code tel que l’on souhaite l’écrire sans l’ajout de lignes de code pour préparer et réaliser les mesures.

Nous avons fait également le point sur le fonctionnement de la JVM (de manière succincte, car nous pourrions consacrer un article entier sur ce sujet).

Au final, en prenant en considération les points précédents, il semble indispensable d’utiliser un outil adapté. JMH est l’une des solutions. Il est simple d’utilisation et plutôt bien documenté sur la Toile.