Tutoriel sur l'injection de données avec JMH, l'outil de benchmark de la JDK

Bruno Doolaeghe (soat)

Pour faire suite à mes précédents articles (article 1 et 2) consacrés à JMH, le framework de benchmarking de l'OpenJDK, voici la dernière partie, dans laquelle je m'intéresserai non pas au traitement benchmarké proprement dit, mais à la donnée sur laquelle il s'exécute. Quelle importance cette donnée peut-elle avoir ? En matière de performance, nous allons voir qu'étudier la vitesse d'un programme n'a de sens que dans un contexte précis d'exécution : pour faire un parallèle avec la course à pied, diriez-vous qu'un chrono de 2 h 02 est une belle performance ? Tout dépend s'il s'agit d'un marathon ou d'un 100 m…

Pour réagir à ce tutoriel, un espace de dialogue vous est proposé sur le forum Commentez .

- Titre : Tutoriel sur l'injection de données avec JMH, l'outil de benchmark de la JDK

- Auteur : Bruno Doolaeghe (soat)

- Parution : 16 décembre 2015

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2015 Bruno Doolaeghe (soat). Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Injection de données

- I-A. Un exemple simple

- I-B. L'état géré par JMH

- I-C. State scope

- II. Fixtures JMH

- II-A. EN THÉORIE

- II-B. … Et en pratique

- III. Benchmark paramétré

- III-A. Un premier exemple

- III-B. Deuxième exemple

- IV. Conclusion

- V. Remerciements

I - Injection de données

Lorsque l'on benchmark un morceau de code, on rencontre parfois des problématiques un peu similaires à celles des tests unitaires ; notamment, se pose la question de l'isolation du code cible avec le reste du monde.

I-A - Un exemple simple

Prenons le benchmark suivant, qui mesure le coût de l'ajout d'un jour (rolling) à une date donnée :

1. @Benchmark

2. public Calendar benchmark_roll_new_Date() {

3. // create a calendar set to "now"

4. Calendar c = Calendar.getInstance();

5. // roll "now" of 1 day

6. c.roll(Calendar.DAY_OF_YEAR, 1);

7. return c;

8. }

Ce benchmark est-il correct ? En un sens oui, il mesure le temps d'exécution de ce morceau de code. Néanmoins, ce n'est pas ce que l'on cherchait à mesurer : certes, on mesure ici le temps de rolling d'une date, mais on y ajoute également le temps de construction d'une instance de Calendar, ainsi que (et surtout) le temps d'un appel OS, pour récupérer la date système ! Cette opération système n'est pas gratuite, et peut même être significativement plus longue que le code que l'on souhaite réellement benchmarker, faussant alors complètement notre benchmark !

I-B - L'état géré par JMH

Heureusement, JMH prend en charge l'injection de données : il suffit de déclarer un type contenant un « état » (décoré d'un @State) à injecter au benchmark :

1. @State(Scope.Benchmark)

2. public static class DateContainer {

3. // an object DateContainer will be instanciatedd

4. // out of benchmark time by JMH Runner

6. // get system date (long time operation)

7. Calendar now = Calendar.getInstance();

9. // get a copy of "now" calendar (quick operation)

10. Calendar getCalendarSetToNow() {

11. return (Calendar) now.clone();

12. }

13. }

15. @Benchmark

16. public Calendar benchmark_roll_date_in_argument(DateContainer d) {

17. // get calendar for "now", created before benchmark

18. Calendar c =  d.getCalendarSetToNow();

19. // roll date

20. c.roll(Calendar.DAY_OF_YEAR, 1);

21. return c;

22. }

De cette manière, le Runner JMH instanciera hors benchmark un objet DateContainer et le passera en argument à la méthode benchmark_roll_date_in_argument(). On peut ainsi se rendre compte de l'overhead dû à l'appel système pour récupérer la date, en comparant les résultats des deux benchmarks ci-dessus :

Benchmark

Mode

Cnt

Score

Error

Units

benchmark_roll_date_in_argument

avgt

10

88,728

± 10,282

ns/op

benchmark_roll_new_Date

avgt

10

252,941

± 107,716

ns/op

Près de 60 % du temps (158 sur 252 ns) est passé dans l'appel système ! Celui-ci est donc loin d'être anodin.

I-C - State scope

Vous remarquerez par ailleurs que le @State porte un Scope qui définit la visibilité de l'objet. En effet, que se passe-t-il lorsque le benchmark est réalisé en multithreadé ?

Le Scope répond à cette question :

- en Scope.Banchmark, l'objet sera partagé parmi tous les threads gérés par JMH ;

- en Scope.Thread, chaque thread aura son propre objet non partagé ;

- en Scope.Group, l'objet sera partagé, mais au sein d'un groupe de threads seulement.

II - Fixtures JMH

Nous venons de voir que JMH gère pour nous le cycle de vie des objets @State, qui sont injectés en paramètre des méthodes de benchmark. Une question demeure cependant : à quel moment durant les cycles itératifs de JMH, ces objets sont-ils instanciés ?

II-A - EN THÉORIE

À la manière de JUnit, il existe dans JMH des « fixtures » pour initialiser et nettoyer ce contexte d'exécution avant et après le benchmark. Ainsi nous pouvons dire à JMH comment initialiser/détruire nos objets @State, en implémentant des méthodes annotées par un @Setup et @TearDown. Par ailleurs, on précisera un Level sur ces annotations, qui définira à quel moment elles doivent être appelées par le Runner :

- Level.Trial, déclenchera une exécution au début/à la fin du benchmark (i.e. la séquence d'itérations) ;

- Level.Iteration, déclenchera une exécution avant/après chaque itération ;

- Level.Invocation, déclenchera une exécution avant/après chaque invocation. (Attention, comme indiqué dans la javadoc, ce level peut aboutir à des résultats totalement biaisés sur des opérations < 1 ms ! Pour utilisateur averti.)

II-B - … Et en pratique

Dans l'exemple qui suit, on souhaite benchmarker le tri d'un tableau ; on utilisera alors les « fixtures » pour :

1. Créer un tableau d'entiers non trié au début du benchmark ;

2. Recopier ce tableau non trié avant chaque invocation, afin de le trier (on triera ainsi toujours les mêmes données à chaque invocation).

1. @State(Scope.Benchmark)

2. public static class ArrayContainer {

3. // initial unsorted array

4. int [] suffledArray = new int[10000000];

5. // system date copy

6. int [] arrayToSort = new int[10000000];

8. @Setup(Level.Trial)

9. public void initArray() {

10. // create a shuffled array of int

11. // ...

12. }

14. @Setup(Level.Invocation)

15. public void makeArrayCopy() {

16. // copy shuffled array to reinit the array to sort

17. arrayToSort = suffledArray.clone();

18. }

20. int[] getArrayToSort() {

21. return arrayToSort;

22. }

23. }

25. @Benchmark

26. public int[] benchmark_array_sort(ArrayContainer d) {

27. // sort copy of shuffled array

28. Arrays.sort(d.getArrayToSort());

29. // array is now sorted !

30. return d.getArrayToSort();

31. }

Le niveau Level.invocation étant susceptible de fausser le benchmak sur des invocations courtes (< 1 ms), on s'assurera que le coût des « fixtures » est négligeable face au tri, en benchmarkant un code « vide » :

1. @Benchmark

2. public int[] baseline(ArrayContainer d) {

3. // empty benchmark check the Level.Invocation setup fixture

4. // does not impact significativly the measures on benchmark_array_sort()

5. return null;

6. }

Les résultats obtenus nous confirmeront que l'appel des « fixtures » est bien indolore (0.001 << 10.222 ms) pour notre benchmark du tri :

Benchmark

Mode

Cnt

Score

Error

Units

baseline

avgt

10

0,001

± 0,000

ms/op

benchmark_array_sort

avgt

10

10,222

± 0,065

ms/op

III - Benchmark paramétré

Enfin, last but not least, il existe une annotation @Param grâce à laquelle il est possible de paramétrer un objet @State, comme pour les tests JUnit paramétrés. Le benchmark sera alors exécuté consécutivement avec chacune des valeurs du paramètre !

III-A - Un premier exemple

Prenons le code du benchmark suivant :

1. @State(Scope.Benchmark)

2. public static class ArrayContainer {

3. @Param({ "10000", "100000", "1000000", "10000000", "100000000"})

4. int arraySize;

6. // initial unsorted array

7. int [] suffledArray;

9. // system date copy

10. int [] arrayToSort;

12. @Setup(Level.Trial)

13. public void initArray() {

14. // create a shuffled array of int

15. suffledArray = new int[arraySize];

16. // ...

17. }

18. // ...

19. }

Nous pouvons grâce à cela, jouer sur la taille du tableau à trier, et montrer ainsi la corrélation taille du tableau/temps de traitement :

Benchmark

(arraySize)

Mode

Cnt

Score

Error

Units

baseline

10000

avgt

10

0,000

± 0,000

ms/op

baseline

100000

avgt

10

0,000

± 0,000

ms/op

baseline

1000000

avgt

10

0,001

± 0,000

ms/op

baseline

10000000

avgt

10

0,001

± 0,000

ms/op

baseline

100000000

avgt

10

0,003

± 0,000

ms/op

benchmark_array_sort

10000

avgt

10

0,466

± 0,028

ms/op

benchmark_array_sort

100000

avgt

10

4,208

± 0,136

ms/op

benchmark_array_sort

1000000

avgt

10

39,313

± 0,569

ms/op

benchmark_array_sort

10000000

avgt

10

395,908

± 4,964

ms/op

benchmark_array_sort

100000000

avgt

10

4042,115

± 42,876

ms/op

Ces résultats nous montrent :

1. Que les mesures réalisées dans le contexte de ce benchmark ne sont pas biaisées par la fixture de niveau Level.invocation : en effet, les temps « baseline » (< 3 µs) sont très inférieurs aux temps des benchmark_array_sot() (> 486 µs) ;

2. Que ce tri a une complexité de type n.log(n)), comme le montre la courbe ci-dessous (échelle logarithmique) :

III-B - Deuxième exemple

Nous allons à présent comparer le tri « parallélisé » disponible depuis le jdk 8 via l'API stream avec le tri « classique », à travers ce benchmark :

1. @State(Scope.Benchmark)

2. public static class ArrayContainer {

3. @Param({ "10", "15", "20", "25", "30", "40", "60", "80", "100", "200", "400", "800", "1000", "10000", "100000", "1000000", "10000000" })

4. int arraySize;

5. // …

6. }

8. @Benchmark

9. public int[] benchmark_array_sort(ArrayContainer d) {

10. // parallel sort

11. return Arrays.stream(d.getArrayToSort())

12. .parallel()

13. .sorted()

14. .toArray();

15. }

17. @Benchmark

18. public int[] baseline(ArrayContainer d) {

19. // sort

20. return Arrays.stream(d.getArrayToSort())

21. .sorted()

22. .toArray();

23. }

Bien évidemment, les processeurs actuels tout comme Rodrigue, ont beaucoup de cœurs, on s'attend donc à en bénéficier dans le tri parallèle ! Néanmoins, les résultats que nous obtenons sont plus subtils que cela (échelle logarithmique) :

Nous pouvons constater que le tri parallèle est effectivement plus rapide (jusqu'à 2 fois), mais seulement à partir d'une taille critique (quelques milliers d'entiers) : en dessous de cette taille, le tri parallèle est jusqu'à 10 fois plus lent, le coup de la parallélisation étant supérieur au gain qu'elle apporte !

IV - Conclusion

À travers ces quelques exemples, nous avons vu tout d'abord qu'un benchmark ne fait de sens que si l'on précise le contexte dans lequel il a été réalisé. On peut bien sûr y trouver une multitude de paramètres : configuration hardware, OS, architecture… mais surtout les données d'entrée, qui jouent un rôle très important ! En effet, les temps de traitement y sont la plupart du temps corrélés (leur volume bien sûr, mais aussi leur type, ou leur valeur). Heureusement, JMH fournit la boîte à outils pour paramétrer ses benchmarks, et ainsi :

- établir le type de corrélation qui peut exister (proportionnelle ? quadratique ? exponentielle ?) et la quantifier grâce aux mesures réalisées ;

- déterminer des points d'inflexion ou des points de rupture (volume maximal de données qu'un algorithme peut traiter avant saturation) ;

- et même identifier des plages sur lesquelles le comportement peut changer.

Pour terminer enfin sur le sujet des benchmarks, je rappellerai une des leçons tirées de la physique quantique qui, malgré son « bilan mitigé », nous aura démontré que l'observation influence le phénomène observé, y compris quand il s'agit d'un benchmark : attention donc !

V - Remerciements

Cet article a été publié avec l'aimable autorisation de la société Soat.

Nous tenons à remercier Claude Leloup pour sa relecture attentive de cet article et milkoseck pour la mise au gabarit.