web-dev-qa-db-fra.com

amélioration des performances inexpliquée de 10% + du simple fait d'ajouter un argument de méthode (code Jit plus fin)

(note: la bonne réponse doit aller au-delà de la reproduction).

Après des millions d'invocations, quicksort1 est définitivement plus rapide que quicksort2, qui possède un code identique, à part cet argument supplémentaire. 

Le code est à la fin du post. Spoiler: J'ai aussi trouvé que le code Jit est plus gros de 224 octets même s'il devrait être réellement plus simple (comme le dit la taille du code en octets; voir la dernière mise à jour ci-dessous).

Même après avoir essayé de prendre en compte cet effet avec un harnais de type microbenchmark (JMH), la différence de performances est toujours présente. 

Je demande: POURQUOI existe-t-il une telle différence de code natif généré et que fait-il?

En ajoutant un argument à une méthode, cela le rend plus rapide ...! Je connais les effets de gc/jit/warmup/etc. Vous pouvez exécuter le code tel quel ou avec un nombre d'itérations plus grand ou plus petit. En fait, vous devriez même commenter l'un, l'autre test, puis les exécuter dans des instances distinctes de JVM simplement pour prouver que ce n'est pas une interférence entre eux.

Le bytecode ne montre pas beaucoup de différence, mis à part l'évident getstatic pour sleft/sright mais aussi un étrange 'iload 4' au lieu de "iload_3" (et istore 4/istore_3)

Que diable se passe t'il? Le iload_3/istore_3 est-il vraiment plus lent que le iload 4/istore 4? Et que beaucoup plus lentement que même l'appel getstatic ajouté ne le ralentit toujours pas? Je peux deviner que les champs statiques sont inutilisés, le jit peut donc simplement le sauter. 

Quoi qu'il en soit, il n'y a pas d'ambiguïté de mon côté car elle est toujours reproductible, et je cherche l'explication de la raison pour laquelle le javac/jit a fait ce qu'il a fait et pourquoi la performance est tellement affectée. Ce sont des algo récursifs identiques avec les mêmes données, la même mémoire vive, etc. Je ne pouvais pas effectuer de modification plus isolée si je le voulais, pour montrer une différence significative d’exécution reproductible.

Env: 

Java version "1.8.0_161" 
Java(TM) SE Runtime Environment (build 1.8.0_161-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.161-b12, mixed mode)
(also tried and reproduced on Java9)
on a 4 core i5 laptop 8GB ram.
windows 10 with the meltdown/specter patch.

Avec -verbose: gc -XX: + PrintCompilation, il n'y a pas de compilation gc et jit s'est stabilisée dans C2 (niveau 4).

Avec n = 20000:

main]: qs1: 1561.3336199999999 ms (res=null)
main]: qs2: 1749.748416 ms (res=null)

main]: qs1: 1422.0767509999998 ms (res=null)
main]: qs2: 1700.4858689999999 ms (res=null)

main]: qs1: 1519.5280269999998 ms (res=null)
main]: qs2: 1786.2206899999999 ms (res=null)

main]: qs1: 1450.0802979999999 ms (res=null)
main]: qs2: 1675.223256 ms (res=null)

main]: qs1: 1452.373318 ms (res=null)
main]: qs2: 1634.581156 ms (res=null)

BTW, belle Java9 semble rendre les deux plus rapides mais toujours 10-15% de réduction l'un de l'autre.:

[0.039s][info][gc] Using G1
main]: qs1: 1287.062819 ms (res=null)
main]: qs2: 1451.041391 ms (res=null)

main]: qs1: 1240.800305 ms (res=null)
main]: qs2: 1391.2404299999998 ms (res=null)

main]: qs1: 1257.1707159999999 ms (res=null)
main]: qs2: 1433.84716 ms (res=null)

main]: qs1: 1233.7582109999998 ms (res=null)
main]: qs2: 1394.7195849999998 ms (res=null)

main]: qs1: 1250.885867 ms (res=null)
main]: qs2: 1413.88437 ms (res=null)

main]: qs1: 1261.5805739999998 ms (res=null)
main]: qs2: 1458.974334 ms (res=null)

main]: qs1: 1237.039902 ms (res=null)
main]: qs2: 1394.823751 ms (res=null)

main]: qs1: 1255.14672 ms (res=null)
main]: qs2: 1400.693295 ms (res=null)

main]: qs1: 1293.009808 ms (res=null)
main]: qs2: 1432.430952 ms (res=null)

main]: qs1: 1262.839628 ms (res=null)
main]: qs2: 1421.376644 ms (res=null)

CODE (TESTS COMPRIS):

(S'il vous plaît ne faites pas attention à quel point ce tri rapide est; c'est à côté de la question).

import Java.util.Random;
import Java.util.concurrent.Callable;

public class QuicksortTrimmed {

    static void p(Object msg) {
        System.out.println(Thread.currentThread().getName()+"]: "+msg);
    }

    static void perf(int N, String msg, Callable c) throws Exception {
        Object res = null;
        long t = System.nanoTime();
        for(int i=0; i<N; i++) {
            res = c.call();
        }
        Double d = 1e-6*(System.nanoTime() - t);
        p(msg+": "+d+" ms (res="+res+")");
    }

    static String und = "__________";//10
    static {
        und += und;//20
        und += und;//40
        und += und;//80
        und += und;//160
    }

    static String sleft = "//////////";//10
    static {
        sleft += sleft;//20
        sleft += sleft;//40
        sleft += sleft;//80
        sleft += sleft;//160
    }

    static String sright= "\\\\\\\\\\\\\\\\\\\\";//10
    static {
        sright += sright;//20
        sright += sright;//40
        sright += sright;//80
        sright += sright;//160
    }

    //============

    public static void main(String[] args) throws Exception {
        int N = 20000;
        int n = 1000;
        int bound = 10000;
        Random r = new Random(1);
        for(int i=0; i<5; i++) {
            testperf(N, r, n, bound);
            //System.gc();
        }
    }

    static void testperf(int N, Random r, int n, int bound) throws Exception {
        final int[] orig = r.ints(n, 0, bound).toArray();
        final int[] a = orig.clone();

        perf(N, "qs1", () -> {
            System.arraycopy(orig, 0, a, 0, orig.length);
            quicksort1(a, 0, a.length-1, und);
            return null;
        });

        perf(N, "qs2", () -> {
            System.arraycopy(orig, 0, a, 0, orig.length);
            quicksort2(a, 0, a.length-1);
            return null;
        });
        System.out.println();
    }


    private static void quicksort1(int[] a, final int _from, final int _to, String mode) {
        int len = _to - _from + 1;
        if(len==2) {
            if(a[_from] > a[_to]) {
                int tmp = a[_from];
                a[_from] = a[_to];
                a[_to] = tmp;
            }
        } else { //len>2
            int mid = _from+len/2;
            final int pivot = a[mid];
            a[mid] = a[_to];
            a[_to] = pivot; //the pivot value is the 1st high value

            int i = _from;
            int j = _to;

            while(i < j) {
                if(a[i] < pivot)
                    i++;
                else if(i < --j) { //j is the index of the leftmost high value 
                    int tmp = a[i];
                    a[i] = a[j];  //THIS IS HARMFUL: maybe a[j] was a high value too.
                    a[j] = tmp;
                }
            }

            //swap pivot in _to with other high value in j
            int tmp = a[j];
            a[j] = a[_to];
            a[_to] = tmp;

            if(_from < j-1)
                quicksort1(a, _from, j-1, sleft);
            if(j+1 < _to)
                quicksort1(a, j+1, _to, sright);
        }
    }

    private static void quicksort2(int[] a, final int _from, final int _to) {
        int len = _to - _from + 1;
        if(len==2) {
            if(a[_from] > a[_to]) {
                int tmp = a[_from];
                a[_from] = a[_to];
                a[_to] = tmp;
            }
        } else { //len>2
            int mid = _from+len/2;
            final int pivot = a[mid];
            a[mid] = a[_to];
            a[_to] = pivot; //the pivot value is the 1st high value

            int i = _from;
            int j = _to;

            while(i < j) {
                if(a[i] < pivot)
                    i++;
                else if(i < --j) { //j is the index of the leftmost high value 
                    int tmp = a[i];
                    a[i] = a[j];  //THIS IS HARMFUL: maybe a[j] was a high value too.
                    a[j] = tmp;
                }
            }

            //swap pivot in _to with other high value in j
            int tmp = a[j];
            a[j] = a[_to];
            a[_to] = tmp;

            if(_from < j-1)
                quicksort2(a, _from, j-1);
            if(j+1 < _to)
                quicksort2(a, j+1, _to);
        }
    }

}

METTRE À JOUR: 

J'ai fait le test JMH et il s'avère toujours que quicksort1 est plus rapide que quicksort2.

# Run complete. Total time: 00:13:38

Benchmark                    Mode  Cnt      Score    Error  Units
MyBenchmark.testQuickSort1  thrpt  200  14861.437 ± 86.707  ops/s
MyBenchmark.testQuickSort2  thrpt  200  13097.209 ± 46.178  ops/s

Voici le repère de JMH:

package org.sample;

import Java.util.Random;

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.infra.Blackhole;

public class MyBenchmark {
    static String und = "__________";//10
    static {
        und += und;//20
        und += und;//40
        und += und;//80
        und += und;//160
    }

    static String sleft = "//////////";//10
    static {
        sleft += sleft;//20
        sleft += sleft;//40
        sleft += sleft;//80
        sleft += sleft;//160
    }

    static String sright= "\\\\\\\\\\\\\\\\\\\\";//10
    static {
        sright += sright;//20
        sright += sright;//40
        sright += sright;//80
        sright += sright;//160
    }

    static final int n = 1000;
    static final int bound = 10000;
    static Random r = new Random(1);
    static final int[] orig = r.ints(n, 0, bound).toArray();

    @State(Scope.Thread)
    public static class ThrState {
        int[] a;

        @Setup(Level.Invocation)
        public void setup() {
            a = orig.clone();
        }
    }

    //============

    @Benchmark
    public void testQuickSort1(Blackhole bh, ThrState state) {
        int[] a = state.a;
        quicksort1(a, 0, a.length-1, und);
        bh.consume(a);
    }

    @Benchmark
    public void testQuickSort2(Blackhole bh, ThrState state) {
        int[] a = state.a;
        quicksort2(a, 0, a.length-1);
        bh.consume(a);
    }


    private static void quicksort1(int[] a, final int _from, final int _to, String mode) {
        int len = _to - _from + 1;
        if(len==2) {
            if(a[_from] > a[_to]) {
                int tmp = a[_from];
                a[_from] = a[_to];
                a[_to] = tmp;
            }
        } else { //len>2
            int mid = _from+len/2;
            final int pivot = a[mid];
            a[mid] = a[_to];
            a[_to] = pivot; //the pivot value is the 1st high value

            int i = _from;
            int j = _to;

            while(i < j) {
                if(a[i] < pivot)
                    i++;
                else if(i < --j) { //j is the index of the leftmost high value 
                    int tmp = a[i];
                    a[i] = a[j];  //THIS IS HARMFUL: maybe a[j] was a high value too.
                    a[j] = tmp;
                }
            }

            //swap pivot in _to with other high value in j
            int tmp = a[j];
            a[j] = a[_to];
            a[_to] = tmp;

            if(_from < j-1)
                quicksort1(a, _from, j-1, sleft);
            if(j+1 < _to)
                quicksort1(a, j+1, _to, sright);
        }
    }

    private static void quicksort2(int[] a, final int _from, final int _to) {
        int len = _to - _from + 1;
        if(len==2) {
            if(a[_from] > a[_to]) {
                int tmp = a[_from];
                a[_from] = a[_to];
                a[_to] = tmp;
            }
        } else { //len>2
            int mid = _from+len/2;
            final int pivot = a[mid];
            a[mid] = a[_to];
            a[_to] = pivot; //the pivot value is the 1st high value

            int i = _from;
            int j = _to;

            while(i < j) {
                if(a[i] < pivot)
                    i++;
                else if(i < --j) { //j is the index of the leftmost high value 
                    int tmp = a[i];
                    a[i] = a[j];  //THIS IS HARMFUL: maybe a[j] was a high value too.
                    a[j] = tmp;
                }
            }

            //swap pivot in _to with other high value in j
            int tmp = a[j];
            a[j] = a[_to];
            a[_to] = tmp;

            if(_from < j-1)
                quicksort2(a, _from, j-1);
            if(j+1 < _to)
                quicksort2(a, j+1, _to);
        }
    }

}

METTRE À JOUR: 

A ce moment, j'ai couru et capturé un journal jit pour jitwatch (j'ai utilisé la balise 1.3.0 et construit à partir de https://github.com/AdoptOpenJDK/jitwatch/tree/1.3.0 )

-verbose:gc
-XX:+PrintGCDateStamps
-XX:+PrintGCDetails
-Xloggc:"./gc.log"
-XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=1M
-XX:+PrintGCApplicationStoppedTime
-XX:+PrintCompilation
-XX:+PrintSafepointStatistics
-XX:PrintSafepointStatisticsCount=1
-XX:+UnlockDiagnosticVMOptions  -XX:+LogCompilation -XX:+PrintInlining

Il n’ya pas de "suggestions" évidentes de la part de jitwatch, juste la règle trop grande pour être insérée ou trop profonde, de toute façon pour quicksort1 et quicksort2.

La découverte importante est le bytecode et la différence de code natif:

Avec argument de méthode supplémentaire (quicksort1): Code d'octet = 187 octets Code natif = 1872 octets

Sans argument de méthode supplémentaire (quicksort2): Code d'octet = 178 octets (plus petit de 9 octets) Code natif = 2 096 octets (plus grand de 224 octets !!!)

Ceci est une preuve solide que le code Jit est plus gros et plus lent dans quicksort2.

Alors, la question demeure: que pensait le compilateur C2 Jit? quelle règle a permis de créer du code natif plus rapide lorsque j'ajoute un argument de méthode et 2 références statiques à charger et à transmettre

J'ai finalement compris le code de l'Assemblée, mais comme je m'y attendais, il est presque impossible de faire la différence et de comprendre ce qui se passe. J'ai suivi l'instruction la plus récente que j'ai trouvée dans https://stackoverflow.com/a/24524285/2023577 . J'ai un fichier journal XML de 7 Mo (compressé à 675 Ko) que vous pouvez obtenir et voir pendant 7 jours (expire le 4 mai 2018) à l'adresse { https://wetransfer.com/downloads/65fe0e94ab409d57cba1b95459064dd420180427150905/612dc9 peut donner un sens (dans jitwatch bien sûr!).

Le paramètre string ajouté conduit à un code d'assemblage plus compact. La question (toujours sans réponse) est pourquoi? Qu'est-ce qui est différent dans le code d'assemblage? Quelle est la règle ou l'optimisation qui n'est pas utilisée dans le code le plus lent?

14
user2023577

Je pense avoir observé quelque chose d'étrange dans le code de l'Assemblée.

Tout d'abord, j'ai ajouté des lignes vides de sorte que quicksort1 commence à la ligne 100 et quicksort2 commence à la ligne 200. Il est beaucoup plus simple d'aligner le code d'assembly.

J'ai également changé l'argument string en un argument, juste pour tester et prouver que le type n'est pas le problème.

Après une tâche fastidieuse d’alignement du code asm dans Excel, voici le fichier xls: https://wetransfer.com/downloads/e56fd98fe248cef98f5a242bbdd64f6920180430130753/7b8f2b .__ (disponible 7 jours). (Je suis désolé si je ne suis pas cohérent dans ma couleur, j'en ai marre ...) 

Le schéma que je vois est qu'il y a plus d'opérations de déménagement pour préparer le quicksort2. Si je comprends bien, l'inline du code natif serait plus long, et à cause de la récursion, il dégénérera quelques niveaux mais suffisamment pour provoquer le ralentissement. Je ne comprends pas assez bien les opérations pour deviner au-delà de cela.

En d'autres termes, lorsque les dernières images d'empilement triées du dernier point de retour de récursivité peuvent être alignées, il est possible de placer 3 ou 5 niveaux (difficile à dire), puis il faut alors sauter. Cependant, ces images bytecode de quicksort2 utilisant davantage de code natif pour des raisons peu claires ajoutent des centaines d'opérations supplémentaires.

À ce stade, je suis à 50% dans la réponse. C2 crée un code légèrement plus gros, mais est gonflé à cause de l'inline des cadres de queue récursifs.

Je pense que je vais rapporter un bogue à Oracle ... Cela a été tout un défi, mais au final, il est très décevant que du code Java inutilisé entraîne de plus mauvaises performances!

0
user2023577

Reproduction et analyse

J'ai pu reproduire vos résultats. Données de la machine:

Linux #143-Ubuntu x86_64 GNU/Linux
Java version "1.8.0_171"
Java(TM) SE Runtime Environment (build 1.8.0_171-b11)
Java HotSpot(TM) 64-Bit Server VM (build 25.171-b11, mixed mode)

J'ai un peu réécrit votre code et j'ai fait des tests supplémentaires. Votre temps de test inclut l'appel System.arraycopy(). J'ai créé une structure de tableau de 400 Mo et l'ai enregistrée:

int[][][] data = new int[iterations][testCases][];
for (int iteration = 0; iteration < data.length; iteration++) {
    for (int testcase = 0; testcase < data[iteration].length; testcase++) {
        data[iteration][testcase] = random.ints(numberCount, 0, bound).toArray();
    }
}

FileOutputStream fos = new FileOutputStream("test_array.dat");
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(data);

Après cela, j'ai exécuté ces tests (échauffement, exécution du démontage):

{
    FileInputStream fis = new FileInputStream(fileName);
    ObjectInputStream iis = new ObjectInputStream(fis);
    int[][][] data = (int[][][]) iis.readObject();


    perf("qs2", () -> {
        for (int iteration = 0; iteration < data.length; iteration++) {
            for (int testCase = 0; testCase < data[iteration].length; testCase++) {
                quicksort2(data[iteration][testCase], 0, data[iteration][testCase].length - 1);
            }
        }
        return null;
    });
}
{
    FileInputStream fis = new FileInputStream(fileName);
    ObjectInputStream iis = new ObjectInputStream(fis);
    int[][][] data = (int[][][]) iis.readObject();


    perf("qs1", () -> {
        for (int iteration = 0; iteration < data.length; iteration++) {
            for (int testCase = 0; testCase < data[iteration].length; testCase++) {
                quicksort1(data[iteration][testCase], 0, data[iteration][testCase].length - 1, und);
            }
        }
        return null;
    });
}

Dans le cas où je lance les qs1 et qs2 ensemble:

main]: qs1: 6646.219874 ms (res=null)
main]: qs2: 7418.376646 ms (res=null)

Le résultat n'est pas dépendant de l'ordre d'exécution:

main]: qs2: 7526.215395 ms (res=null)
main]: qs1: 6624.261529 ms (res=null)

J'ai également exécuté le code dans de nouvelles instances de machine virtuelle Java:

Première instance:

main]: qs1: 6592.699738 ms (res=null)

Deuxième instance:

main]: qs2: 7456.326028 ms (res=null)

Si vous l'essayez sans le JIT:

-Djava.compiler=NONE

Les résultats sont ceux "attendus" (le plus petit code est plus rapide): 

main]: qs1: 56547.589942 ms (res=null)
main]: qs2: 53585.909246 ms (res=null)

Pour une meilleure analyse, j'ai extrait les codes dans deux classes différentes.

J'utilisais jclasslib pour l'inspection de bytecode. La méthode dure pour moi:

Q1: 505
Q2: 480

Cela a du sens pour l'exécution sans le JIT:

53585.909246×505÷480 = 56376.842019229

Ce qui est vraiment proche de 56547.589942.

Raison

Pour moi, dans la sortie de la compilation (avec -XX:+PrintCompilation), j'ai ces lignes

1940  257       2       QS1::sort (185 bytes)
1953  258 %     4       QS1::sort @ 73 (185 bytes)
1980  259       4       QS1::sort (185 bytes)
1991  257       2       QS1::sort (185 bytes)   made not entrant
9640  271       3       QS2::sort (178 bytes)
9641  272       4       QS2::sort (178 bytes)
9654  271       3       QS2::sort (178 bytes)   made not entrant

% signifie lors du remplacement de la pile (où le code compilé est en cours d'exécution) . Selon ce journal, l'appel avec le paramètre extra String est optimisé et le second ne l'est pas. Je pensais à une meilleure prédiction de branche, mais cela ne devrait pas être le cas ici (essayé d’ajouter des chaînes générées aléatoirement comme paramètres). Les tailles d'échantillon (400 Mo) excluent généralement la mise en cache. Je pensais au seuil d'optimisation, mais lorsque j'utilise ces options -Xcomp -XX:+PrintCompilation -Xbatch, le résultat est le suivant:

 6408 3254    b  3       QS1::sort (185 bytes)
 6409 3255    b  4       QS1::sort (185 bytes)
 6413 3254       3       QS1::sort (185 bytes)   made not entrant
14580 3269    b  3       QS2::sort (178 bytes)
14580 3270    b  4       QS2::sort (178 bytes)
14584 3269       3       QS2::sort (178 bytes)   made not entrant

Cela signifie que les metods sont fored blocking compilés avant appel mais les temps restent les mêmes:

main]: qs1: 6982.721328 ms (res=null)
main]: qs2: 7606.077812 ms (res=null)

La clé de ceci, je pense, est la String. Si je modifie le paramètre extra (non utilisé) en int, il est systématiquement traité légèrement plus lentement (avec les paramètres d'optimisation précédents):

main]: qs1: 7925.472909 ms (res=null)
main]: qs2: 7727.628422 ms (res=null)

Ma conclusion est que l'optimisation peut être influencée par le type d'objet de paramètres supplémentaires. Il y a probablement une optimisation moins poussée dans le cas des primitives, ce qui me semble logique, mais je n’ai pas pu trouver la source exacte de cette revendication. 

Une lecture intéressante supplémentaire.

8
Mark