For vs Stream
Cela faisait longtemps que je n’avais pas écrit un article de blog et pourtant, pour cause de confinement, j’ai du temps pour le faire!
N’ayant pas d’idée, j’ai demandé à ma twitosphère de m’en donner, et j’ai eu une réponse intéressante :
Les différences de perf entre stream et for classique ? On en a parlé y’a longtemps, mais j’avais fait une session de micro bench avec JMH qui montrait que les ‘for’ étaient plus performants que les streams.
— Jérémy Thulliez (@JeremyThulliez) April 14, 2020
Voici donc un article parlant des différences de performance entre une boucle for et un Stream Java.
Tout d’abord, parlons un peu de benchmark. Comme on veut comparer les performances d’une boucle for et des Stream en Java, il nous faut un outil qui permet de réaliser des micro-benchmarks de manière pertinente. En effet, quand on réalise des micro-benchmarks, on peut rencontrer pas mal de soucis quand ce que l’on veut mesurer est en dessous de la dizaine de microsecondes : un simple passage d’un garbage collector par exemple peut rendre la mesure inopérante. Il faut aussi correctement pré-chauffer la JVM pour tenir compte de l’effet du Just In Time Compiler (le JIT), sans parler de mesurer correctement le temps (et non, System.currentTimeInMillis()
n’est pas l’apanage ici).
Pour parer à tous ces soucis, il n’y a qu’une seule solution, JMH – The Java Microbenchmark Harness. Cet outil nous permet d’écrire des micro-benchmarks pertinents en tenant compte des caractéristiques internes de la JVM. Il offre aussi des outils intégrés pour analyser les performances de nos tests (profiler, disassembler, …). Si vous ne connaissez pas JMH, vous pouvez vous référez à cet article : INTRODUCTION À JMH – JAVA MICROBENCHMARK HARNESS.
Tous les tests ont été exécutés sur mon laptop : Intel(R) Core(TM) i7-8750H 6 coeurs (12 avec hyperthreading) – Ubuntu 19.10.
Benchmark 1 : à vide
Pour notre premier test, comparons les performances à vide via un test le plus simple possible, que l’on va exécuter sur une liste à taille variable (10, 1 000, 10 000 éléments).
// the test will be done for 10, 1000 and 10000 elements @Param({"10", "1000", "10000"}) int size; Listlist; @Setup public void setup() { // we setup the test with the test data list = new ArrayList<>(size); for (int i = 0; i < size; i++) { list.add(i); } } @Benchmark // the Blackhole is used to avoid dead code elimination (DCE) public void testForLoop_doNothing(Blackhole bh) { for (Integer i : list) { bh.consume(i); } } @Benchmark public void testStream_doNothing(Blackhole bh) { list.stream().forEach(i -> bh.consume(i)); }
Quand on lance un benchmark via JMH, le warning suivant est affiché, merci d’en tenir compte 😉
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.
Et voici les résultats en nanoseconde par opération (le plus bas est le meilleur) avec la JVM OpenJdk 11.0.5.
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.testStream_doNothing 10 avgt 5 51,563 ± 3,682 ns/op ForVsStream.testStream_doNothing 1000 avgt 5 3634,718 ± 513,237 ns/op ForVsStream.testStream_doNothing 10000 avgt 5 36757,820 ± 2956,085 ns/op
Pour de petite valeur de liste, l’utilisation d’une boucle for est plus rapide, mais pour des valeurs plus importante (mais toujours assez petite), les Stream sont plus performants !
La différence pour la plus petite valeur d’itération s’explique facilement, l’exécution d’un Stream nécessite la création d’objets Java et celle-ci a un coût qui est important au regard du coût d’exécution quand il y a peu d’itérations. Lancer le benchmark avec -prof gc
(profiler intégré à JMH en mode garbage collection) permet de voir que pour 10 éléments on a quasiment aucune allocation mémoire pour une boucle for mais 192 Mo/s d’allocation mémoire pour un Stream.
Par contre, pour un nombre d’itérations plus grand, on n’a quasiment plus d’allocations mémoire et la différence de performance au profit d’un Stream devient difficilement explicable. En regardant le code généré par le JIT (via -prof perfasm
) on peut voir qu’il y a beaucoup moins d’instructions générées pour un forEach
de Stream que pour une boucle for, entre autre à chaque tour de boucle for on fait un appel à ArrayList.next()
qui fait lui-même un appel à ArrayList.checkForComodification()
. Je pense qu’en se basant sur des tableaux et pas des listes les résultats auraient été différents.
Benchmark 2 : somme de nombres
Testons maintenant avec une opération un peu plus complexe : on va réaliser la somme des entiers de la liste. Comme on peut le faire de nombreuses manières différentes avec les Stream on va en tester deux : un reduce
et un mapToInt
(qui transforme en IntStream) suivi d’un sum
.
@Benchmark public int testForLoop_Accumulation() { int acc = 0; for (Integer i : list) { acc += i; } return acc; } @Benchmark public Integer testStream_AccumulationByMap() { return list.stream().mapToInt(Integer::valueOf).sum(); } @Benchmark public Integer testStream_AccumulationByReduce() { return list.stream().reduce(0, Integer::sum); }
Et voici les résultats :
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 ForVsStream.testStream_AccumulationByMap 10 avgt 5 51,954 ± 3,562 ns/op ForVsStream.testStream_AccumulationByMap 1000 avgt 5 4638,615 ± 394,359 ns/op ForVsStream.testStream_AccumulationByMap 10000 avgt 5 52717,408 ± 4893,117 ns/op ForVsStream.testStream_AccumulationByReduce 10 avgt 5 33,495 ± 2,197 ns/op ForVsStream.testStream_AccumulationByReduce 1000 avgt 5 5635,545 ± 476,337 ns/op ForVsStream.testStream_AccumulationByReduce 10000 avgt 5 43167,492 ± 3646,626 ns/op
Là on a une différence flagrante entre une boucle for et l’utilisation d’un Stream : avec 10000 éléments la boucle for est quasiment 10x plus rapide! Ce qui est intéressant c’est que les deux implémentations avec les Stream offrent des performances similaires.
Quand on regarde les allocations mémoire, on a pour le coup énormément d’allocations mémoire pour 1000 et 10000 éléments pour les Stream (environ 35O Mo/s et 425 Mo/s pour le mapToInt, 30% moins pour le reduce).
Apparemment, c’est bien la création d’objet intermédiaire nécessaires pour les opérations sur les Streams qui impacte les performances.
Le JIT Graal est cité comme ayant des optimisations plus poussées sur les Stream, lançons donc le benchmark avec Graal au lieu du JIT par défaut d’OpenJDK : C2.
J’ai utilisé GraalVM 19.3.1-java11 pour plus de simplicité mais on peut utiliser le JIT Graal avec OpenJDK.
ForVsStream.testForLoop_Accumulation 10 avgt 5 25,960 ± 1,414 ns/op ForVsStream.testForLoop_Accumulation 1000 avgt 5 2422,501 ± 385,645 ns/op ForVsStream.testForLoop_Accumulation 10000 avgt 5 25197,850 ± 6781,623 ns/op ForVsStream.testStream_AccumulationByMap 10 avgt 5 50,210 ± 3,361 ns/op ForVsStream.testStream_AccumulationByMap 1000 avgt 5 1601,862 ± 53,173 ns/op ForVsStream.testStream_AccumulationByMap 10000 avgt 5 15947,271 ± 759,564 ns/op ForVsStream.testStream_AccumulationByReduce 10 avgt 5 35,362 ± 8,530 ns/op ForVsStream.testStream_AccumulationByReduce 1000 avgt 5 4279,177 ± 151,122 ns/op ForVsStream.testStream_AccumulationByReduce 10000 avgt 5 43256,515 ± 1942,682 ns/op
Graal semble optimiser beaucoup mieux l’implémentation via mapToInt().sum() que C2, par contre les performances via une boucle for sont deux fois moins bonne.
Comme on ne retrouve pas ces différences sur les autres benchmarks (les résultats ne sont pas intégrés dans cette article mais sont très proches).
On peut penser que c’est ici un problème éphémère au moment du lancement du test, j’ai donc relancé le benchmark et j’ai toujours eu le même résultat! Les boucles for sont moins bien optimisées avec Graal. J’ai donc demandé aux développeurs de Graal ce qu’ils en pensent, je mettrais à jour l’article avec des explications si j’en ai 😉 .
@thomaswue @shelajev I make some microbenchmarks to compare performance of for loop vs Stream. I run them with C2 and Graal and saw some differences.
1. Trivial for loop that performs a sum of ints is 2x – 4x slower.
2. Stream that uses mapToInt().sum() is 3x quicker.
Results ⬇️— Loïc Mathieu (@loicmathieu) April 20, 2020
Benchmark 3 : concaténation de String
Finissons par un exemple de concaténation de String, celui-ci devrait être moins sensible au problème de différences de création d’objets entre une boucle for et un Stream.
@Benchmark public String testForLoop_StringBuilder() { StringBuilder builder = new StringBuilder(); for (Integer i : list) { builder.append(i.toString()); } return builder.toString(); } @Benchmark public String testStream_StringBuilderByForEach() { StringBuilder builder = new StringBuilder(); list.stream().forEach(i -> builder.append(i.toString())); return builder.toString(); } @Benchmark public String testStream_StringBuilderByJoining() { return list.stream().map(i -> i.toString()).collect(Collectors.joining()); }
Et voici les résultats :
ForVsStream.testForLoop_StringBuilder 10 avgt 5 148,218 ± 11,181 ns/op ForVsStream.testForLoop_StringBuilder 1000 avgt 5 19277,681 ± 524,130 ns/op ForVsStream.testForLoop_StringBuilder 10000 avgt 5 189820,108 ± 39519,566 ns/op ForVsStream.testStream_StringBuilderByForEach 10 avgt 5 124,379 ± 4,701 ns/op ForVsStream.testStream_StringBuilderByForEach 1000 avgt 5 19643,228 ± 1296,197 ns/op ForVsStream.testStream_StringBuilderByForEach 10000 avgt 5 179761,808 ± 17014,818 ns/op ForVsStream.testStream_StringBuilderByJoining 10 avgt 5 242,916 ± 27,689 ns/op ForVsStream.testStream_StringBuilderByJoining 1000 avgt 5 19560,014 ± 3750,284 ns/op ForVsStream.testStream_StringBuilderByJoining 10000 avgt 5 207933,016 ± 17316,623 ns/op
Entre une boucle for et un forEach sur un Stream, les performances sont quasiment identiques. Quand on réalise des opérations non triviales, il n’y a quasiment aucune différence de performance ente une boucle for et un Stream !
Quand on utilise Collectors.joining()
, les performances sont légèrement moins bonnes mais restent assez proches pour un nombre important d’itérations.
Conclusion
La différence de performance entre utiliser une boucle for et un Stream est quasiment négligeable en elle même, ce qui change, c’est bien la nature des opérations qu’on réalise. Dans le cas des Stream, on peut facilement réaliser beaucoup plus d’opérations pour la même tâche (exemple de la somme des nombres).
Une des forces des Stream est de faciliter la création de pipeline d’opérations : à nous de faire attention et d’être pragmatique dans son utilisation !
Pour finir, je tiens à rappeler que ce sont des micro-benchmarks : bien souvent, dans un cas réel d’application, le pipeline d’opérations sera beaucoup plus grand et donc la différence de performance négligeable. Quand on prend en compte les optimisations du JIT et la lisibilité du code, bien souvent, utiliser un Stream est la meilleur solution 😉
Pour ceux qui voudraient jouer avec le benchmark, il est disponible ici : https://github.com/loicmathieu/jmh-benchmarks/blob/master/src/main/java/fr/loicmathieu/jmh/ForVsStream.java
Un remerciement tout spécial à Logan pour sa relecture et la correction des nombreuses fautes d’orthographe 😉