Java 8: performances des flux vs collections

140

Je suis nouveau sur Java 8. Je ne connais toujours pas l'API en profondeur, mais j'ai fait un petit benchmark informel pour comparer les performances de la nouvelle API Streams avec les bonnes anciennes Collections.

Le test consiste à filtrer une liste de Integer, et pour chaque nombre pair, calculer la racine carrée et la stocker dans un résultat ListdeDouble .

Voici le code:

    public static void main(String[] args) {
        //Calculating square root of even numbers from 1 to N       
        int min = 1;
        int max = 1000000;

        List<Integer> sourceList = new ArrayList<>();
        for (int i = min; i < max; i++) {
            sourceList.add(i);
        }

        List<Double> result = new LinkedList<>();


        //Collections approach
        long t0 = System.nanoTime();
        long elapsed = 0;
        for (Integer i : sourceList) {
            if(i % 2 == 0){
                result.add(Math.sqrt(i));
            }
        }
        elapsed = System.nanoTime() - t0;       
        System.out.printf("Collections: Elapsed time:\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));


        //Stream approach
        Stream<Integer> stream = sourceList.stream();       
        t0 = System.nanoTime();
        result = stream.filter(i -> i%2 == 0).map(i -> Math.sqrt(i)).collect(Collectors.toList());
        elapsed = System.nanoTime() - t0;       
        System.out.printf("Streams: Elapsed time:\t\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));


        //Parallel stream approach
        stream = sourceList.stream().parallel();        
        t0 = System.nanoTime();
        result = stream.filter(i -> i%2 == 0).map(i -> Math.sqrt(i)).collect(Collectors.toList());
        elapsed = System.nanoTime() - t0;       
        System.out.printf("Parallel streams: Elapsed time:\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));      
    }.

Et voici les résultats pour une machine dual core:

    Collections: Elapsed time:        94338247 ns   (0,094338 seconds)
    Streams: Elapsed time:           201112924 ns   (0,201113 seconds)
    Parallel streams: Elapsed time:  357243629 ns   (0,357244 seconds)

Pour ce test particulier, les flux sont environ deux fois plus lents que les collections, et le parallélisme n'aide pas (ou je l'utilise de la mauvaise manière?).

Des questions:

  • Ce test est-il juste? Ai-je fait une erreur?
  • Les flux sont-ils plus lents que les collections? Quelqu'un a-t-il fait une bonne référence formelle à ce sujet?
  • Quelle approche dois-je miser?

Résultats mis à jour.

J'ai exécuté le test 1k fois après le préchauffage de la JVM (1k itérations) comme conseillé par @pveentjer:

    Collections: Average time:      206884437,000000 ns     (0,206884 seconds)
    Streams: Average time:           98366725,000000 ns     (0,098367 seconds)
    Parallel streams: Average time: 167703705,000000 ns     (0,167704 seconds)

Dans ce cas, les flux sont plus performants. Je me demande ce que l'on observerait dans une application où la fonction de filtrage n'est appelée qu'une ou deux fois pendant l'exécution.

Monsieur Smith
la source
1
l'avez-vous essayé avec un à la IntStreamplace?
Mark Rotteveel
2
Pouvez-vous s'il vous plaît mesurer correctement? Si tout ce que vous faites est une course, alors vos points de repère seront bien sûr éteints.
skiwi
2
@MisterSmith Pouvons-nous avoir une certaine transparence sur la façon dont vous avez réchauffé votre JVM, également avec des tests 1K?
skiwi
1
Et pour ceux qui souhaitent écrire des microbenchmarks corrects, voici la question: stackoverflow.com/questions/504103/…
Mister Smith
2
@assylias Using toListdevrait s'exécuter en parallèle même s'il est collecté dans une liste non thread-safe, car les différents threads seront collectés dans des listes intermédiaires confinées aux threads avant d'être fusionnés.
Stuart marque

Réponses:

192
  1. Arrêtez d'utiliser LinkedListpour autre chose que la suppression lourde du milieu de la liste à l'aide d'itérateur.

  2. Arrêtez d'écrire du code d'analyse à la main, utilisez JMH .

Bonnes références:

@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
@OperationsPerInvocation(StreamVsVanilla.N)
public class StreamVsVanilla {
    public static final int N = 10000;

    static List<Integer> sourceList = new ArrayList<>();
    static {
        for (int i = 0; i < N; i++) {
            sourceList.add(i);
        }
    }

    @Benchmark
    public List<Double> vanilla() {
        List<Double> result = new ArrayList<>(sourceList.size() / 2 + 1);
        for (Integer i : sourceList) {
            if (i % 2 == 0){
                result.add(Math.sqrt(i));
            }
        }
        return result;
    }

    @Benchmark
    public List<Double> stream() {
        return sourceList.stream()
                .filter(i -> i % 2 == 0)
                .map(Math::sqrt)
                .collect(Collectors.toCollection(
                    () -> new ArrayList<>(sourceList.size() / 2 + 1)));
    }
}

Résultat:

Benchmark                   Mode   Samples         Mean   Mean error    Units
StreamVsVanilla.stream      avgt        10       17.588        0.230    ns/op
StreamVsVanilla.vanilla     avgt        10       10.796        0.063    ns/op

Tout comme je m'attendais à ce que la mise en œuvre du flux soit assez lente. JIT est capable d'incorporer tous les éléments lambda mais ne produit pas de code aussi parfaitement concis que la version vanilla.

En général, les flux Java 8 ne sont pas magiques. Ils ne pouvaient pas accélérer les choses déjà bien implémentées (avec, probablement, des itérations simples ou des instructions for-each de Java 5 remplacées par des appels Iterable.forEach()et Collection.removeIf()). Les flux concernent davantage la commodité et la sécurité du codage. Commodité - compromis de vitesse fonctionne ici.

Leventov
la source
2
Merci d'avoir pris le temps de mettre cela au banc. Je ne pense pas que changer LinkedList pour ArrayList changerait quoi que ce soit, car les deux tests devraient y ajouter, les heures ne devraient pas être affectées. Quoi qu'il en soit, pouvez-vous expliquer les résultats? Il est difficile de dire ce que vous mesurez ici (les unités disent ns / op, mais qu'est-ce qui est considéré comme une opération?).
Mister Smith
52
Votre conclusion sur les performances, bien que valable, est exagérée. Il existe de nombreux cas où le code de flux est plus rapide que le code itératif, en grande partie parce que les coûts d'accès par élément sont moins chers avec les flux qu'avec les itérateurs simples. Et dans de nombreux cas, la version streams en ligne avec quelque chose qui est équivalent à la version manuscrite. Bien sûr, le diable est dans les détails; tout bit de code donné peut se comporter différemment.
Brian Goetz
26
@BrianGoetz, pourriez-vous s'il vous plaît spécifier des cas d'utilisation, lorsque les flux sont plus rapides?
Alexandr
1
Dans la dernière version de FMH: utiliser à la @Benchmarkplace de@GenerateMicroBenchmark
pdem
3
@BrianGoetz, pourriez-vous spécifier des cas d'utilisation, lorsque les flux sont plus rapides?
kiltek
17

1) Vous voyez le temps inférieur à 1 seconde en utilisant votre benchmark. Cela signifie que les effets secondaires peuvent avoir une forte influence sur vos résultats. Alors, j'ai augmenté votre tâche 10 fois

    int max = 10_000_000;

et a exécuté votre benchmark. Mes résultats:

Collections: Elapsed time:   8592999350 ns  (8.592999 seconds)
Streams: Elapsed time:       2068208058 ns  (2.068208 seconds)
Parallel streams: Elapsed time:  7186967071 ns  (7.186967 seconds)

sans edit ( int max = 1_000_000) les résultats étaient

Collections: Elapsed time:   113373057 ns   (0.113373 seconds)
Streams: Elapsed time:       135570440 ns   (0.135570 seconds)
Parallel streams: Elapsed time:  104091980 ns   (0.104092 seconds)

C'est comme vos résultats: le flux est plus lent que la collecte. Conclusion: beaucoup de temps a été consacré à l'initialisation du flux / à la transmission des valeurs.

2) Après avoir augmenté, le flux de tâches est devenu plus rapide (ce n'est pas grave), mais le flux parallèle est resté trop lent. Qu'est-ce qui ne va pas? Remarque: vous avez collect(Collectors.toList())dans votre commande. La collecte dans une seule collection introduit essentiellement un goulot d'étranglement et une surcharge de performances en cas d'exécution simultanée. Il est possible d'estimer le coût relatif des frais généraux en remplaçant

collecting to collection -> counting the element count

Pour les flux, cela peut être fait par collect(Collectors.counting()). J'ai obtenu des résultats:

Collections: Elapsed time:   41856183 ns    (0.041856 seconds)
Streams: Elapsed time:       546590322 ns   (0.546590 seconds)
Parallel streams: Elapsed time:  1540051478 ns  (1.540051 seconds)

C'est pour une grosse tâche! ( int max = 10000000) Conclusion: la collecte des articles à la collecte a pris la majorité du temps. La partie la plus lente est l'ajout à la liste. BTW, simple ArrayListest utilisé pour Collectors.toList().

Sergey Fedorov
la source
Vous devez effectuer un microbenchmark de ce test, ce qui signifie qu'il doit d'abord être réchauffé plusieurs fois, puis exécuté de nombreux tests et moyenné.
skiwi
@skiwi bien sûr, vous avez raison, surtout parce qu'il y a de grands écarts dans les mesures. Je n'ai fait qu'une enquête de base et je ne prétends pas que les résultats soient précis.
Sergey Fedorov
Le JIT en mode serveur démarre après 10 000 exécutions. Et puis il faut un certain temps pour compiler le code et l'échanger.
pveentjer
À propos de cette phrase: " vous avez collect(Collectors.toList())dans votre commande, c'est-à-dire qu'il peut y avoir une situation où vous devez adresser une seule collection par plusieurs threads. " Je suis presque sûr que cela toListrassemble plusieurs instances de liste différentes en parallèle. Ce n'est qu'à la dernière étape de la collection que les éléments sont transférés dans une liste, puis renvoyés. Il ne devrait donc pas y avoir de surcharge de synchronisation. C'est pourquoi les collecteurs ont à la fois une fonction fournisseur, un accumulateur et une fonction combinateur. (Cela pourrait être lent pour d'autres raisons, bien sûr.)
Lii
@Lii Je pense la même chose à propos de la collectmise en œuvre ici. Mais à la fin, plusieurs listes devraient être fusionnées en une seule, et il semble que la fusion soit l'opération la plus lourde dans l'exemple donné.
Sergey Fedorov
4
    public static void main(String[] args) {
    //Calculating square root of even numbers from 1 to N       
    int min = 1;
    int max = 10000000;

    List<Integer> sourceList = new ArrayList<>();
    for (int i = min; i < max; i++) {
        sourceList.add(i);
    }

    List<Double> result = new LinkedList<>();


    //Collections approach
    long t0 = System.nanoTime();
    long elapsed = 0;
    for (Integer i : sourceList) {
        if(i % 2 == 0){
            result.add( doSomeCalculate(i));
        }
    }
    elapsed = System.nanoTime() - t0;       
    System.out.printf("Collections: Elapsed time:\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));


    //Stream approach
    Stream<Integer> stream = sourceList.stream();       
    t0 = System.nanoTime();
    result = stream.filter(i -> i%2 == 0).map(i -> doSomeCalculate(i))
            .collect(Collectors.toList());
    elapsed = System.nanoTime() - t0;       
    System.out.printf("Streams: Elapsed time:\t\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));


    //Parallel stream approach
    stream = sourceList.stream().parallel();        
    t0 = System.nanoTime();
    result = stream.filter(i -> i%2 == 0).map(i ->  doSomeCalculate(i))
            .collect(Collectors.toList());
    elapsed = System.nanoTime() - t0;       
    System.out.printf("Parallel streams: Elapsed time:\t %d ns \t(%f seconds)%n", elapsed, elapsed / Math.pow(10, 9));      
}

static double doSomeCalculate(int input) {
    for(int i=0; i<100000; i++){
        Math.sqrt(i+input);
    }
    return Math.sqrt(input);
}

Je change un peu le code, j'ai couru sur mon mac book pro qui a 8 cœurs, j'ai obtenu un résultat raisonnable:

Collectes: Temps écoulé: 1522036826 ns (1,522037 secondes)

Streams: Temps écoulé: 4315833719 ns (4,315834 secondes)

Flux parallèles: Temps écoulé: 261152901 ns (0,261153 secondes)

Mellon
la source
Je pense que votre test est juste, vous avez juste besoin d'une machine avec plus de cœurs de processeur.
Mellon
3

Pour ce que vous essayez de faire, je n'utiliserais de toute façon pas les API Java classiques. Il y a une tonne de boxe / déballage en cours, donc il y a une énorme surcharge de performance.

Personnellement, je pense que beaucoup d'API conçues sont de la merde car elles créent beaucoup de déchets d'objets.

Essayez d'utiliser un tableau primitif de double / int et essayez de le faire avec un seul thread et voyez quelles sont les performances.

PS: Vous voudrez peut-être jeter un œil à JMH pour vous occuper de faire le benchmark. Il prend en charge certains des pièges typiques tels que le réchauffement de la JVM.

pveentjer
la source
Les LinkedLists sont encore pires que les ArrayLists car vous devez créer tous les objets nœud. L'opérateur de mod est également lent à chien. Je crois que quelque chose comme 10/15 cycles + cela draine le pipeline d'instructions. Si vous souhaitez effectuer une division très rapide par 2, déplacez simplement le nombre de 1 bit vers la droite. Ce sont des astuces de base, mais je suis sûr qu'il existe des astuces avancées en mode pour accélérer les choses, mais celles-ci sont probablement plus spécifiques au problème.
pveentjer
Je suis au courant de la boxe. Ceci est juste une référence informelle. L'idée est d'avoir la même quantité de boxing / unboxing dans les collections et les tests de flux.
Mister Smith
Tout d'abord, je m'assurerais qu'il ne s'agit pas d'une erreur de mesure. Essayez d'exécuter le benchmark plusieurs fois avant de faire le véritable benchmark. Ensuite, au moins, vous avez le préchauffage de la JVM et le code est correctement JITTED. Sans cela, vous tirez probablement les mauvaises conclusions.
pveentjer
Ok, je publierai de nouveaux résultats suite à vos conseils. J'ai jeté un œil à JMH mais il nécessite Maven et la configuration prend un certain temps. Merci quand même.
Mister Smith
Je pense qu'il vaut mieux éviter de penser aux tests de référence en termes de "Pour ce que vous essayez de faire". c'est-à-dire que ces types d'exercices sont généralement suffisamment simplifiés pour être démontrables, mais suffisamment complexes pour qu'ils semblent pouvoir / devraient être simplifiés.
ryvantage