For vs Stream

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 :

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;

    List list;

    @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 😉 .

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 😉

Laisser un commentaire

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.