web-dev-qa-db-fra.com

Pourquoi deux boucles séparées sont-elles plus rapides qu'une?

Je veux comprendre le type d’optimisation que Java fait pour effectuer des boucles for consécutives. Plus précisément, j'essaie de vérifier si la fusion de boucle est effectuée . En théorie, je m'attendais à ce que cette optimisation ne soit pas effectuée automatiquement et je m'attendais à confirmer que la version fusionnée était plus rapide que la version à deux boucles.

Cependant, après avoir exécuté les tests de performance, les résultats montrent que deux boucles distinctes (et consécutives) sont plus rapides qu'une seule boucle effectuant tout le travail.

J'ai déjà essayé d'utiliser JMH pour créer les points de repère et j'ai obtenu les mêmes résultats.

J'ai utilisé la commande javap et elle montre que le pseudo-code généré pour le fichier source avec deux boucles correspond en fait à l'exécution de deux boucles (aucun déroulement de boucle ou autre optimisation n'a été effectué).

Code en cours de mesure pour BenchmarkMultipleLoops.Java:

private void work() {
        List<Capsule> intermediate = new ArrayList<>();
        List<String> res = new ArrayList<>();
        int totalLength = 0;

        for (Capsule c : caps) {
            if(c.getNumber() > 100000000){
                intermediate.add(c);
            }
        }

        for (Capsule c : intermediate) {
            String s = "new_Word" + c.getNumber();
            res.add(s);
        }

        //Loop to assure the end result (res) is used for something
        for(String s : res){
            totalLength += s.length();
        }

        System.out.println(totalLength);
    }

Code en cours de mesure pour BenchmarkSingleLoop.Java:

private void work(){
        List<String> res = new ArrayList<>();
        int totalLength = 0;

        for (Capsule c : caps) {
            if(c.getNumber() > 100000000){
                String s = "new_Word" + c.getNumber();
                res.add(s);
            }
        }

        //Loop to assure the end result (res) is used for something
        for(String s : res){
            totalLength += s.length();
        }

        System.out.println(totalLength);
    }

Et voici le code pour Capsule.Java:

public class Capsule {
    private int number;
    private String Word;

    public Capsule(int number, String Word) {
        this.number = number;
        this.Word = Word;
    }

    public int getNumber() {
        return number;
    }

    @Override
    public String toString() {
        return "{" + number +
                ", " + Word + '}';
    }
}

caps est un ArrayList<Capsule> avec 20 millions d'éléments peuplés comme ceci au début:

private void populate() {
        Random r = new Random(3);

        for(int n = 0; n < POPSIZE; n++){
            int randomN = r.nextInt();
            Capsule c = new Capsule(randomN, "Word" + randomN);
            caps.add(c);
        }
    }

Avant de mesurer, une phase d'échauffement est exécutée.

J'ai exécuté chacun des tests 10 fois ou, en d'autres termes, la méthode work() est exécutée 10 fois pour chaque test et les délais moyens à compléter sont présentés ci-dessous (en secondes). Après chaque itération, le GC a été exécuté avec quelques temps morts:

  • MultipleLoops: 4.9661 secondes
  • SingleLoop: 7.2725 secondes

OpenJDK 1.8.0_144 s'exécutant sur un Intel i7-7500U (Kaby Lake).

Pourquoi la version MultipleLoops est-elle plus rapide que la version SingleLoop, même si elle doit traverser deux structures de données différentes?

UPDATE 1:

Comme suggéré dans les commentaires, si je modifie l'implémentation pour calculer totalLength lors de la génération de chaînes, en évitant la création de la liste res, la version à boucle unique devient plus rapide. 

Cependant, cette variable n'a été introduite que pour que certains travaux soient effectués après la création de la liste des résultats, afin d'éviter de supprimer les éléments si rien n'a été fait avec eux.

En d'autres termes, le résultat souhaité est de produire la liste finale}. Mais cette suggestion aide à mieux comprendre ce qui se passe.

Résultats:

  • MultipleLoops: 0.9339 secondes
  • SingleLoop: 0.66590005 secondes

MISE À JOUR 2:

Voici un lien vers le code que j'ai utilisé pour le benchmark JMH: https://Gist.github.com/FranciscoRibeiro/2d3928761f76e4f7cecfcfcdfcfcdfcfc96d5

Résultats:

  • MultipleLoops: 7.397 secondes
  • SingleLoop: 8.092 secondes
23
Francisco Ribeiro

J'ai enquêté sur ce "phénomène" et j'ai l'air d'avoir quelque chose comme une réponse.
Ajoutons .jvmArgs("-verbose:gc") à JMHs OptionsBuilder. Résultats pour 1 itération:

Boucle simple: [Full GC (Ergonomie) [PSYoungGen: 2097664K-> 0K (2446848K)] [ParOldGen: 3899819K-> 4574771K (5592576K)] 5997483K-> 4574771K (8039424K), [Metaspace: 6 , 5.0438301 secondes] [Temps: utilisateur = 37,92 sys = 0,10, réel = 5,05 secondes] 4.954 s/op

Boucles multiples: [Full GC (Ergonomie) [PSYoungGen: 2097664K-> 0K (2446848K)] [ParOldGen: 3899819K-> 4490913K (5592576K)] 5997483K-> 4490913K (8039424K), [Metaspace: 620] , 3,7991573 secondes] [Temps: utilisateur = 26,84 sys = 0,08, réel = 3,80 secondes] 4.187 s/op

La machine virtuelle Java a consacré énormément de temps processeur à la GC. Une fois tous les 2 tests, la machine virtuelle Java doit effectuer un calcul complet de la capacité de stockage (transférer 600 Mo vers OldGen et collecter 1,5 Go de déchets des cycles précédents). Les deux éboueurs ont fait le même travail, mais ont passé environ 25% moins de temps à appliquer pour plusieurs tests de boucle. Si nous réduisons la POPSIZE à 10_000_000 ou si nous ajoutons à avant bh.consume()Thread.sleep(3000), ou si nous ajoutons -XX:+UseG1GC aux arguments de la machine virtuelle Java, l'effet de renforcement de la boucle multiple disparaît. Je le lance encore une fois avec .addProfiler(GCProfiler.class). La différence principale:

Boucles multiples: gc.churn.PS_Eden_Space 374.417 ± 23 Mo/s

Boucle simple: gc.churn.PS_Eden_Space 336.037 Mo/s ± 19 Mo/s

Je pense que nous constatons une accélération dans de telles circonstances spécifiques, car les algorithmes très performants de Comparaison et Swap GC ont un goulot d'étranglement de processeur pour les tests multiples et utilisent un cycle supplémentaire "insensé" pour Collect Garbage des exécutions précédentes. Il est encore plus facile de reproduire avec @Threads(2), si vous avez assez de RAM. Cela ressemble à ceci, si vous essayez de profiler le test Single_Loop:

profiling

2
Anton Kot

Pour comprendre ce qui se passe sous le capot, vous pouvez ajouter un comportement JMX afin d'analyser l'application en cours d'exécution dans jvisualvm, situé dans Java_HOME\bin mémoire insuffisante et visualvm est passé dans l’état de non-réponse. J'avais réduit la taille de la liste de capsules à 200k et de 100M à 1M si je pouvais tester. Après avoir observé le comportement sur visualvm, l'exécution d'une seule boucle s'est terminée avant plusieurs boucles. Peut-être que ce n'est pas la bonne approche, mais vous pouvez expérimenter avec.

LoopBean.Java

import Java.util.List;
public interface LoopMBean {
    void multipleLoops();
    void singleLoop();
    void printResourcesStats();
}

Loop.Java

import Java.util.ArrayList;
import Java.util.List;
import Java.util.Random;

public class Loop implements LoopMBean {

    private final List<Capsule> capsules = new ArrayList<>();

    {
        Random r = new Random(3);
        for (int n = 0; n < 20000000; n++) {
            int randomN = r.nextInt();
            capsules.add(new Capsule(randomN, "Word" + randomN));
        }
    }

    @Override
    public void multipleLoops() {

        System.out.println("----------------------Before multiple loops execution---------------------------");
        printResourcesStats();

        final List<Capsule> intermediate = new ArrayList<>();
        final List<String> res = new ArrayList<>();
        int totalLength = 0;

        final long start = System.currentTimeMillis();

        for (Capsule c : capsules)
            if (c.getNumber() > 100000000) {
                intermediate.add(c);
            }

        for (Capsule c : intermediate) {
            String s = "new_Word" + c.getNumber();
            res.add(s);
        }

        for (String s : res)
            totalLength += s.length();

        System.out.println("multiple loops=" + totalLength + " | time taken=" + (System.currentTimeMillis() - start) + " milliseconds");

        System.out.println("----------------------After multiple loops execution---------------------------");
        printResourcesStats();

        res.clear();
    }

    @Override
    public void singleLoop() {

        System.out.println("----------------------Before single loop execution---------------------------");
        printResourcesStats();

        final List<String> res = new ArrayList<>();
        int totalLength = 0;

        final long start = System.currentTimeMillis();

        for (Capsule c : capsules)
            if (c.getNumber() > 100000000) {
                String s = "new_Word" + c.getNumber();
                res.add(s);
            }

        for (String s : res)
            totalLength += s.length();

        System.out.println("Single loop=" + totalLength + " | time taken=" + (System.currentTimeMillis() - start) + " milliseconds");
        System.out.println("----------------------After single loop execution---------------------------");
        printResourcesStats();

        res.clear();
    }

    @Override
    public void printResourcesStats() {
        System.out.println("Max Memory= " + Runtime.getRuntime().maxMemory());
        System.out.println("Available Processors= " + Runtime.getRuntime().availableProcessors());
        System.out.println("Total Memory= " + Runtime.getRuntime().totalMemory());
        System.out.println("Free Memory= " + Runtime.getRuntime().freeMemory());
    }
}

LoopClient.Java

import javax.management.MBeanServer;
import javax.management.ObjectName;
import Java.lang.management.ManagementFactory;

public class LoopClient {

    void init() {

        final MBeanServer mBeanServer = ManagementFactory.getPlatformMBeanServer();
        try {
            mBeanServer.registerMBean(new Loop(), new ObjectName("LOOP:name=LoopBean"));
        } catch (Exception e) {
            e.printStackTrace();
        }

    }

    public static void main(String[] args) {

        final LoopClient client = new LoopClient();
        client.init();
        System.out.println("Loop client is running...");
        waitForEnterPressed();
    }

    private static void waitForEnterPressed() {
        try {
            System.out.println("Press  to continue...");
            System.in.read();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

Exécuter avec la commande suivante:

Java -Dcom.Sun.management.jmxremote -Dcom.Sun.management.jmxremote.port=9999 -Dcom.Sun.management.jmxremote.authenticate=false -Dcom.Sun.management.jmxremote.ssl=false LoopClient

Vous pouvez ajouter -Xmx3072M option supplémentaire pour une augmentation rapide de la mémoire afin d'éviter OutOfMemoryError

1
Aditya