web-dev-qa-db-de.com

unerklärliche 10% + Leistungssteigerung durch einfaches Hinzufügen eines Methodenarguments

(Hinweis: Die richtige Antwort muss über die Reproduktion hinausgehen).

Nach Millionen von Aufrufen ist quicksort1 definitiv schneller als quicksort2, die abgesehen von diesem zusätzlichen Argument identischen Code haben. 

Der Code steht am Ende der Post. Spoiler: Ich fand auch, dass der Jit-Code um 224 Bytes fetter ist, auch wenn er eigentlich einfacher sein sollte (wie der Bytecodegröße mitteilt; siehe letztes Update unten).

Selbst wenn versucht wurde, diesen Effekt mit einem Microbenchmark-Kabelbaum (JMH) auszugleichen, ist der Leistungsunterschied immer noch vorhanden. 

Ich frage: WARUM gibt es einen solchen Unterschied in nativem Code und was macht er?

Durch Hinzufügen eines Arguments zu einer Methode wird es schneller ...! Ich kenne Gc/Jit/Warmup/etc-Effekte. Sie können Code so wie er ist oder mit größeren/kleineren Iterationszahlen ausführen. Eigentlich sollten Sie sogar einen anderen Perf-Test auskommentieren und jeden in einer bestimmten Jvm-Instanz ausführen, nur um zu beweisen, dass es sich nicht um eine Interferenz zwischen den beiden handelt.

Der Bytecode zeigt keinen großen Unterschied, abgesehen von dem offensichtlichen Getstatic für sleft/sright aber auch einem seltsamen "iload 4" anstelle von "iload_3" (und istore 4/istore_3).

Was zum Teufel ist hier los? Ist der iload_3/istore_3 wirklich langsamer als der iload 4/istore 4? Und so viel langsamer, dass selbst der hinzugefügte getstatic-Aufruf ihn nicht langsamer macht? Ich kann mir vorstellen, dass statische Felder nicht verwendet werden, so dass der Jit es einfach überspringt. 

Auf jeden Fall gibt es keine Mehrdeutigkeit auf meiner Seite, da sie immer reproduzierbar ist, und ich suche nach einer Erklärung, warum der Javac/Jit das getan hat, was er getan hat und warum die Performance so stark beeinflusst wird. Dies sind identische rekursive Algorithmen mit den gleichen Daten, der gleichen Speicherabwanderung usw. ... Ich könnte keine isoliertere Änderung vornehmen, wenn ich wollte, um einen signifikanten replizierbaren Laufzeitunterschied aufzuzeigen.

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.

Mit -verbose: gc -XX: + PrintCompilation gibt es keine gc und Jit-Compilierung hat sich in C2 (Tier 4) stabilisiert.

Mit 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)

Übrigens, das schöne Java9 scheint beide schneller zu machen, aber immer noch 10-15% von einander abzuheben .:

[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 (EINSCHLIESSLICH TESTS):

(Bitte beachten Sie nicht, wie schlimm dieser Quicksort ist; er steht außer Frage).

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);
        }
    }

}

AKTUALISIEREN: 

Ich habe den JMH-Test gemacht und beweist immer noch, dass quicksort1 schneller ist als 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

Hier ist der JMH-Benchmark:

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);
        }
    }

}

AKTUALISIEREN: 

In diesem Moment habe ich ein Jit-Protokoll für Jitwatch ausgeführt und erfasst (ich habe das 1.3.0-Tag verwendet und aus https://github.com/AdoptOpenJDK/jitwatch/tree/1.3.0 erstellt).

-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

Es gibt keine offensichtlichen "Vorschläge" von jitwatch, nur das Rgular ist zu groß für Inline oder zu tief, für quicksort1 und quicksort2 sowieso.

Die eine wichtige Erkenntnis ist der Unterschied zwischen Bytecode und nativem Code:

Mit zusätzlichem Methodenargument (quicksort1): Bytecode = 187 Byte Nativer Code = 1872 Byte

Ohne zusätzliches Methodenargument (quicksort2): Bytecode = 178 Bytes (um 9 Bytes kleiner) Nativer Code = 2096 Bytes (größer um 224 Bytes !! !)

Dies ist ein starker Beweis dafür, dass der Jit-Code in quicksort2 fetter und langsamer ist.

Die Frage bleibt also: Was dachte der C2-Jit-Compiler? Welche Regel hat dazu geführt, dass der native Code schneller erstellt wird, wenn ich ein Methodenargument und eine statische Referenz zum Laden und Übergeben hinzufüge?

Ich habe endlich den Assembly-Code in die Hand bekommen, aber wie ich es erwartet habe, ist es fast unmöglich zu verstehen und zu verstehen, was passiert. Ich folgte der neuesten Anweisung, die ich unter https://stackoverflow.com/a/24524285/2023577 finden konnte. Ich habe eine 7MB-XML-Protokolldatei (komprimiert auf 675kB), die Sie für 7 Tage abrufen können (ablaufen bis 4. Mai 2018) unter https://wetransfer.com/downloads/65fe0e94ab409d57cba1b95459064dd420180427150905/612dc9 Sinn davon (in Jitwatch natürlich!).

Der hinzugefügte String-Parameter führt zu einem kompakteren Assembly-Code. Die Fragen (noch unbeantwortet) sind warum? Was ist im Assembly-Code anders? Was ist die Regel oder Optimierung, die im langsameren Code nicht verwendet wird?

14
user2023577

Ich denke, ich habe etwas Ungewöhnliches im Assembly-Code festgestellt.

Zuerst habe ich Leerzeilen hinzugefügt, so dass der Quicksort1 in Zeile 100 beginnt und Quicksort2 in Zeile 200 beginnt. Es ist viel einfacher, den Assembly-Code aufzustellen.

Ich habe auch den String arg in ein int arg geändert, nur um zu testen und zu beweisen, dass der Typ nicht das Problem ist.

Nach einer langwierigen Aufgabe, Asm-Code in Excel aufzustellen, ist hier die xls-Datei: https://wetransfer.com/downloads/e56fd98fe248cef98f5a242b2db64f6920180430130753/7b8f2b (Verfügbar für 7 Tage) ). (Es tut mir leid, wenn ich nicht gleichfarbig bin, ich habe es satt ...) 

Das Muster, das ich sehe, ist, dass es mehr Movs gibt, um den Quicksort2 vorzubereiten. Wenn ich es richtig verstanden habe, wäre das Inlining von nativem Code länger und aufgrund von Rekursion degenerieren einige Level, aber genug, um die Verlangsamung herbeizuführen. Ich verstehe die Operationen nicht gut genug, um darüber hinaus zu erraten.

Mit anderen Worten, wenn die letzten Quicksort-Stackframes vom Rekursionsrücksprungpunkt nach oben möglicherweise 3 oder 5 (schwer zu erkennende) Ebenen eingeblendet werden können, dann springt es an. Diese Bytecode-Frames von Quicksort2, die aus unklaren Gründen mehr nativen Code verwenden , ergeben jedoch Hunderte zusätzlicher Operationen.

An diesem Punkt bin ich zu 50% in der Antwort. C2 erzeugt etwas fetteren Code, wird jedoch durch das Inlining rekursiver Tailframes aufgeblasen.

Ich denke, ich werde einen Fehler bei Oracle einreichen ... Dies war eine ziemliche Herausforderung, aber am Ende ist es sehr enttäuschend, dass ungenutzter Java-Code zu einer schlechteren Leistung führt!

0
user2023577

Fortpflanzung und Analyse

Ich konnte Ihre Ergebnisse reproduzieren. Maschinendaten:

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)

Ich habe Ihren Code ein wenig umgeschrieben und einige zusätzliche Tests durchgeführt. Ihre Testzeit umfasst den Aufruf System.arraycopy(). Ich habe eine 400-MB-Array-Struktur erstellt und gespeichert:

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);

Danach habe ich diese Tests (Warmup, Teardown-Lauf) durchgeführt:

{
    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;
    });
}

Falls ich qs1 und qs2 zusammen laufe:

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

Das Ergebnis ist nicht abhängig von der Ausführungsreihenfolge:

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

Ich habe den Code auch in neuen JVM-Instanzen ausgeführt:

Instanz eins:

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

Instanz zwei:

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

Wenn Sie es ohne JIT versuchen:

-Djava.compiler=NONE

Die Ergebnisse sind wie erwartet (der kleinere Bytecode ist schneller): 

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

Zur besseren Analyse habe ich die Codes in zwei verschiedene Klassen extrahiert.

Ich habe jclasslib für die Bytecode-Überprüfung verwendet. Die Methodenlänge für mich:

Q1: 505
Q2: 480

Dies ist sinnvoll für die Ausführung ohne JIT:

53585.909246×505÷480 = 56376.842019229

Welches ist wirklich nahe an 56547.589942.

Grund

Für mich in der Compilierausgabe (mit -XX:+PrintCompilation) habe ich diese Zeilen

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

Wo% bedeutet beim Stack-Austausch (wo der kompilierte Code läuft) . Gemäß diesem Protokoll wird der Aufruf mit dem zusätzlichen String-Parameter optimiert und der zweite nicht. Ich dachte an eine bessere Verzweigungsvorhersage, aber dies sollte hier nicht der Fall sein (versucht, zufällig generierte Strings als Parameter hinzuzufügen). Bei den Stichprobengrößen (400 MB) ist ein Caching zumeist ausgeschlossen. Ich habe über Optimierungsschwelle nachgedacht, aber wenn ich diese Optionen -Xcomp -XX:+PrintCompilation -Xbatch verwende, wird folgende Ausgabe ausgegeben:

 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

Dies bedeutet, dass die Metods vor dem Aufruf zwangsweise blockiert werden, die Zeiten jedoch gleich bleiben:

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

Der Schlüssel dazu ist meiner Meinung nach die String. Wenn ich den zusätzlichen (nicht verwendeten) Parameter in int ändere, wird er ständig etwas langsamer verarbeitet (läuft mit den vorherigen Optimierungsparametern):

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

Meine Schlussfolgerung ist, dass die Optimierung durch den zusätzlichen Objekttyp beeinflusst werden kann. Wahrscheinlich gibt es bei Primitiven weniger eifrige Optimierung, was für mich sinnvoll ist, aber ich konnte keine genaue Quelle für diese Behauptung finden. 

Eine weitere interessante Lektüre.

8
Mark