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

Introduction

Lors de la réalisation des projets pour nos clients, nous appliquons les grands principes de l’ingénierie logicielle. Cela implique de spécifier, construire, tester et maintenir les applications de nos clients.

Ces différentes phases sont réalisées par nos ingénieurs dans leurs rôles respectifs (Project Managers, Business Analysts, Architects, Developers). Chacun de ces acteurs doit garder à l’esprit l’objectif final du client.

Lors de la phase de développement, nos collaborateurs participent à la réussite de ces projets. Les développeurs élaborent, notamment, des algorithmes qui répondront aux besoins finaux.

Puis, vient le temps de l’implémentation de ces algorithmes. C’est au cours de cette phase que les développeurs vont s’interroger sur la meilleure façon de mettre en place ces algorithmes. Pour faire ce choix, ils doivent prendre en considération les différentes possibilités offertes par le langage de programmation utilisé. Il en résulte que deux implémentations d’un même algorithme peuvent différer de par l’organisation du code ou bien par les fonctionnalités du langage utilisées.

Mais alors, comment choisir l’implémentation la mieux adaptée au contexte client ? Au final, ces deux algorithmes donnent le même résultat, et c’est bien tout ce qui compte !

Se focaliser uniquement sur le résultat n’est pas la bonne approche, il faut également se préoccuper de la manière de l’obtenir.

L’un des facteurs pour faire ce choix peut être la performance d’exécution.

« PERFORMANCE », un seul mot, mais qui a beaucoup d’importance. En effet, lors des développements, si les performances ne restent pas constamment dans un coin de l’esprit des développeurs, elles peuvent se rappeler à eux bien plus tard et souvent de manière douloureuse. C’est notamment le cas, lorsque l’application arrive en production et que la volumétrie des données est très différente de celle de leur écosystème de développement. Ou bien lorsque les sollicitations auprès de leur application mettront à rude épreuve leurs algorithmes.

Pour éviter cette situation, les développeurs doivent toujours se poser la question suivante : « Est-ce que mon code s’exécute dans un laps de temps en cohérence avec les performances attendues ? ». Se poser cette question c’est bien, mais y répondre c’est mieux. Mais alors comment faire ? Tout simplement en mesurant les performances.

Mesurer les performances, rien de plus simple.

Mesurer les performances d’un algorithme ne semble pas, à première vue, bien complexe. Pour cela, il suffit de calculer la différence entre le début du processus et sa fin.

Considérons que nous voulons afficher « Hello World » dans la console. Nous allons écrire ce petit programme.

Pour mesurer le temps d’exécution de ce code, nous allons simplement le compléter de la façon suivante.

Ce qui affiche dans la console.

Nous voyons avec cet exemple qu’il n’est pas compliqué de mesurer le temps d’exécution d’un morceau de code.

Mais alors, peut-on vraiment se satisfaire de cette mesure et surtout de cette façon de faire ?

Bien évidemment, la réponse est « non ». Premièrement parce qu’il a fallu modifier le code de la méthode pour ajouter les éléments de mesure. Par conséquent, la méthode n’est pas la même que ce qui était initialement prévu. De facto, nous ne mesurons pas la même chose. Deuxièmement, procéder de la sorte, c’est oublier comment fonctionne la JVM.

Rappel sur le fonctionnement de la JVM

Les applications développées en Java sont constituées de fichiers ayant pour extension « .java », il s’agit du code source. Une fois compilé, ce code source est transformé en « byteCode ».

Lors de l’exécution de l’application, la JVM utilise ce byteCode. Depuis l’arrivée des JVM “Hotspot” (il s’agit d’une JVM offrant des améliorations de traitements et des optimisations) et en fonction de certains critères, la JVM détecte, par le biais du profiler, le code fréquemment exécuté pour l’optimiser (concept du Just-In-Time Compilation).

Si l’optimisation du byteCode est nécessaire, il est transformé en « code natif » qui est au final du langage machine directement reconnu par le processeur.

Au cours de cette opération d’optimisation, la JVM n’hésite pas à supprimer les parties de code jamais appelées ou bien à réaliser des modifications pour éviter de déclarer des variables qui ne servent qu’à un seul endroit.

C’est en réalisant ces optimisations au fil de l’exécution du code que la JVM réduit le temps d’exécution de certaines parties du code. Ce code optimisé est ensuite stocké dans la partie mémoire nommée « Code Cache » afin d’être utilisé à la place du code byteCode.

 

Cette phase d’optimisation a de réelles influences sur les performances d’exécution d’une application. Afin d’obtenir des résultats réalistes, il faut que la JVM, dans laquelle s’exécute le code de l’application, ait pris le temps d’optimiser le byteCode nécessaire et juste nécessaire. En effet, il serait néfaste d’ordonner à la JVM d’optimiser l’intégralité d’une application (ceci est possible par le paramétrage de la JVM).  Vous vous demandez pourquoi ce serait si néfaste ? Et bien tout simplement parce que le coût de l’optimisation, pour le code rarement exécuté, est plus important que son temps d’exécution au format byteCode.

Ces rappels sur le fonctionnement de la JVM permettent de mettre en avant que les optimisations gérées par la JVM ont un réel impact sur les mesures de performances du code.

Pour démontrer ce comportement, voici en exemple quelques extraits de code d’un benchmark ayant pour objectif de comparer les différentes manières mises à notre disposition en Java pour procéder à une recherche au sein d’une liste. Nous pouvons citer For, ForEach, Iterator, Stream&Filter.

Pour contextualiser légèrement ce benchmark, voici quelques détails.

Chaque méthode testée doit rechercher des véhicules d’une certaine couleur dans une liste. L’ensemble des véhicules est généré aléatoirement dans une base H2, puis chargé depuis cette base H2 au sein d’une liste dans le code Java.

Cette liste répertorie pour chacun des véhicules : sa plaque d’immatriculation, sa marque, son modèle et sa couleur. Ci-dessous, un extrait de cette liste.

Exemple de liste de véhicules.

Nous n’allons pas détailler le code de ce benchmark dans cet article, car ce n’est pas le sujet. Toutefois, je vous propose de regarder de plus près l’une des méthodes testant la recherche au travers de l’utilisation de ForEach.

Voici le code de cette méthode nommée testForEach.

Pour solliciter cette méthode testForEach, nous allons utiliser le code suivant.

L’exécution de cet exemple retourne les informations ci-dessous.

Pour mesurer le temps d’exécution de cette méthode, nous allons procéder de la même manière que dans le premier exemple de cet article. C’est-à-dire, en ajoutant des lignes de code à l’intérieur de notre méthode.

Le code de la méthode testForEach devient donc le suivant.

L’exécution de cette méthode nous indique désormais le temps d’exécution comme indiqué ci-dessous.

Si nous exécutons ce programme une deuxième fois, nous obtenons.

Puis une troisième fois.

La question qu’il est légitime de se poser est : « Pourquoi une telle différence de temps d’exécution ? Pourtant le code de ma méthode reste le même ! » L’un des éléments de réponse est tout simplement que, lorsque la JVM exécute du code, ce dernier peut avoir été optimisé (rappelez-vous du byteCode et du code natif). On peut également mettre en avant que l’environnement d’exécution de la JVM peut être impacté par la disponibilité des ressources de la machine physique elle-même. En effet, le CPU ainsi que la mémoire de la machine n’ont probablement pas le même taux de disponibilité à chaque exécution de ce morceau de code. En résumé, les conditions d’exécution ne sont pas toujours les mêmes.

Pour aller plus loin dans cette démonstration, exécutons cette méthode testForEach un nombre de fois assez conséquent (10 000 fois).  Les temps d’exécution obtenus varient de 66 ms à 2 859 ms pour une moyenne se situant aux alentours de 260 ms.

La conclusion de ce test est que la JVM, en fonction du code et du contexte d’exécution, fait des choix qui s’imposent pour améliorer les performances. Ces choix, que nous ne maîtrisons pas, ont de réelles influences sur les mesures de performances.

Alors comment faire pour mesurer le temps d’exécution d’une méthode de manière fiable ?

Cela est possible en isolant l’exécution de la méthode à mesurer, puis en stimulant la JVM avec le code que l’on souhaite mesurer (on parle souvent de Warmup). Cette étape permet de donner le temps à la JVM d’optimiser le code si cela est nécessaire. Ensuite, il faut exécuter le code à mesurer de manière répétitive (aussi appelé itérations). C’est au cours de ces itérations que les mesures seront réalisées de manière à obtenir une moyenne de temps d’exécution fiable. Bien évidemment, il est préférable de procéder à ces mesures avec les moyens les moins intrusifs possibles. C’est-à-dire, en modifiant le moins possible le code de la méthode à tester.

Les précédents extraits de code sont l’exemple même de ce qu’il ne faut pas faire. En fait, nous sommes passés d’une méthode composée de 19 lignes à 28 lignes. Tout cela uniquement pour prendre des mesures. Mais il ne faut pas oublier que cela a des impacts sur les choix des optimisations réalisées par la JVM.

Nous pouvons en conclure que pour réaliser des mesures en prenant en compte les points précédents, il convient d’utiliser un outillage adapté.

Je vous propose, dans l’article suivant, de détailler l’outil JMH (Java Microbenchmark Harness) qui apporte une réponse à cette problématique.