Super langsamer und super schneller Benchmark

    Ein kürzlich veröffentlichter Artikel zur Java-Leistung löste eine Diskussion über die Leistungsmessung aus. Betrachtet man es, muss man traurig feststellen, dass viele Menschen immer noch nicht verstehen, wie schwierig es ist, die Ausführungszeit eines bestimmten Codes korrekt zu messen. Darüber hinaus sind die Menschen überhaupt nicht daran gewöhnt, dass derselbe Code unter verschiedenen Bedingungen zu deutlich unterschiedlichen Zeiten ausgeführt werden kann. Zum Beispiel ist hier eine der Meinungen:


    Wenn ich wissen muss, "welche Sprache für mich bei meiner Aufgabe schneller ist", werde ich den primitivsten Benchmark der Welt ausführen. Wenn der Unterschied signifikant ist (z. B. eine Größenordnung), ist auf dem Benutzercomputer höchstwahrscheinlich alles ungefähr gleich.

    Leider ist der primitivste Benchmark der Welt in der Regel ein schlecht geschriebener Benchmark. Und man darf nicht hoffen, dass der falsche Benchmark das Ergebnis auch bis zu einer Bestellung genau misst. Es kann etwas völlig anderes messen, was sich von der tatsächlichen Leistung eines Programms mit ähnlichem Code völlig unterscheidet. Schauen wir uns ein Beispiel an.


    Angenommen, wir haben die Java 8 Stream-API gesehen und möchten überprüfen, wie schnell sie für einfache Berechnungen funktioniert. Der Einfachheit halber nehmen wir zum Beispiel einen Stream von ganzen Zahlen von 0 bis 99999, die jeweils mit einer Operation quadratisch sind map, und das ist alles, was wir sonst nicht tun. Wir wollen nur die Leistung messen, oder? Aber selbst ein kurzer Blick auf die API reicht aus, um festzustellen, dass die Streams faul sind und IntStream.range(0, 100_000).map(x -> x * x)tatsächlich nichts bewirken. Aus forEachdiesem Grund werden wir eine Terminaloperation hinzufügen , die unser Ergebnis irgendwie nutzt. Erhöhen Sie ihn beispielsweise um eins. Als Ergebnis erhalten wir diesen Test:


    static void test() {
        IntStream.range(0, 100_000).map(x -> x * x).forEach(x -> x++);
    }

    Großartig. Wie kann man messen, wie viel es funktioniert? Jeder weiß: Nimm dir die Zeit am Anfang, die Zeit am Ende und berechne die Differenz! Fügen Sie eine Methode hinzu, die die Zeit misst und das Ergebnis in Nanosekunden zurückgibt:


    static long measure() {
        long start = System.nanoTime();
        test();
        long end = System.nanoTime();
        return end - start;
    }

    Nun drucken Sie einfach das Ergebnis aus. Auf meinem nicht so schnellen Core i7 und Open JDK 8u91 64bit erhalte ich bei verschiedenen Starts eine Zahl im Bereich von etwa 50 bis 65 Millionen Nanosekunden. Das sind 50-65 Millisekunden. Einhunderttausend Quadrat in 50 Millisekunden? Das ist ungeheuerlich! Dies ist nur zwei Millionen Mal pro Sekunde. Vor fünfundzwanzig Jahren waren Computer noch schneller im Quadrat. Java verlangsamt sich schamlos! Oder nicht?


    Tatsächlich führt die erstmalige Verwendung von Lambdas und Stream-APIs in einer Anwendung auf modernen Computern immer zu einer Verzögerung von 50-70 ms. In dieser Zeit müssen in der Tat viele Dinge getan werden:


    • Download-Klassen zur Generierung von Laufzeitdarstellungen von Lambdas (siehe LambdaMetafactory ) und allem, was damit zu tun hat.
    • Laden Sie die Klassen der Stream-API selbst herunter (es gibt viele davon)
    • Für die in unserem Code verwendeten Lambdas (in unserem Fall zwei) wird eine Laufzeitdarstellung generiert.
    • JIT-compiliere das ganze Zeug zumindest irgendwie.

    All dies erfordert viel Zeit und es ist sogar überraschend, dass es möglich ist, innerhalb von 50 ms zu halten. Das alles wird aber genau einmal benötigt.


    Lyrischer Exkurs


    Im Allgemeinen ist es beim dynamischen Laden und Zwischenspeichern von Daten sehr schwierig zu verstehen, was wir gemessen haben. Dies gilt nicht nur für Java. Ein einfacher Bibliotheksaufruf kann das Laden von der Festplatte und die Initialisierung der gemeinsam genutzten Bibliothek einleiten (und sich vorstellen, dass die Festplatte auch in den Ruhemodus übergegangen ist). Infolgedessen kann der Anruf viel länger dauern. Ein Dampfbad nehmen oder nicht? Manchmal muss man. Beispielsweise hat das Laden der gemeinsam genutzten OLE32.DLL-Bibliothek unter Windows 95 viel Zeit in Anspruch genommen und das erste Programm in den Bremsen angekündigt, das versucht, OLE32 zu laden. Dies zwang die Entwickler, wenn möglich, OLE32 nicht so lange wie möglich herunterzuladen, so dass andere Programme schuld waren. An einigen Stellen implementieren andere Bibliotheken sogar Funktionen, die einige OLE32-Funktionen duplizieren. Nur um das Laden von OLE32 zu vermeiden. Lesen Sie mehr über diese Geschichte unterRaymond Chen .

    So haben wir festgestellt, dass unser Benchmark sehr langsam ist, weil dabei viele Dinge gemacht werden, die genau einmal nach dem Laden gemacht werden müssen. Wenn unser Programm länger als eine Sekunde dauern soll, stört uns dies höchstwahrscheinlich nicht. Lassen Sie uns also die JVM "aufwärmen" - diese Messung 100.000 Mal durchführen und das Ergebnis der letzten Messung anzeigen:


    for (int i = 100000; i >= 0; i--) {
        long res = measure(); 
        if(i == 0)
            System.out.println(res);
    }

    Dieses Programm ist schneller als in einer Sekunde abgeschlossen und druckt 70-90 Nanosekunden auf meinem Computer. Das ist super! Also 0,7-0,9 Pikosekunden pro Quadratmeter? Quadriert Java mehr als eine Billion Mal pro Sekunde? Java ist super schnell! Oder nicht?


    Bereits bei der zweiten Iteration wird ein Großteil der obigen Liste ausgeführt und der Prozess wird alle 100 beschleunigt. Als Nächstes kompiliert der JIT-Compiler nach und nach verschiedene Codeteile (viele davon sind in der Stream-API enthalten), sammelt Ausführungsprofile und optimiert weitere. Letztendlich ist JIT intelligent genug, um die gesamte Lambda-Kette zu integrieren und zu erkennen, dass das Ergebnis der Multiplikation nirgendwo verwendet wird. Der naive Versuch, ihn über das Inkrement des JIT-Compilers zu verwenden, hat nicht getäuscht: Diese Operation hat immer noch keine Nebenwirkungen. Der JIT-Compiler hatte nicht die Kraft, den gesamten Stream zu mähen, aber er war in der Lage, die innere Schleife zu mähen, wodurch die Testleistung unabhängig von der Anzahl der Iterationen wurde (Ersetzen IntStream.range(0, 100_000)durch IntStream.range(0, 1_000_000)- das Ergebnis ist dasselbe).


    Übrigens sind in solchen Zeiten Laufzeit und Granularität von Bedeutung nanoTime(). Sogar auf der gleichen Hardware, aber auf verschiedenen Betriebssystemen, können Sie eine deutlich unterschiedliche Antwort erhalten. Mehr dazu bei Alexey Shipilev .


    Also haben wir "den primitivsten Benchmark" geschrieben. Zuerst stellte sich heraus, dass es super langsam war und nach einer kleinen Verbesserung - super schnell, fast eine Million Mal schneller. Wir wollten messen, wie schnell das Quadrieren mithilfe der Stream-API durchgeführt wird. Aber im ersten Test versank diese mathematische Operation in ein Meer von anderen Operationen, und im zweiten Test wurde sie einfach nicht durchgeführt. Seien Sie vorsichtig bei voreiligen Schlussfolgerungen.


    Wo ist die wahrheit Die Wahrheit ist, dass dieser Test nichts mit der Realität zu tun hat. Es erzeugt keine sichtbaren Effekte in Ihrem Programm, das heißt, es tut nichts. In Wirklichkeit schreiben Sie selten Code, der nichts bewirkt, und mit Sicherheit bringt er Ihnen kaum Geld (obwohl es Ausnahmen gibt ). Der Versuch, die Frage zu beantworten, wie lange das Quadrieren innerhalb der Stream-API tatsächlich dauert, ist bedeutungslos: Dies ist eine sehr einfache Operation, und je nach Umgebungscode kann der JIT-Compiler die Schleife mit Multiplikation sehr unterschiedlich kompilieren. Denken Sie daran, dass die Leistung nicht additiv ist: Wenn A x Sekunden und B y Sekunden dauert, ist es überhaupt nicht so, dass A und B x + y Sekunden dauern. Es kann völlig falsch sein.


    Wenn Sie einfache Antworten wünschen, liegt die Wahrheit in echten Programmen irgendwo dazwischen: Der Overhead eines Streams für 100.000 ganze Zahlen im Quadrat ist ungefähr 1000-mal höher als ein superschnelles Ergebnis und ungefähr 1000-mal niedriger als super langsam. Aber abhängig von vielen Faktoren kann es schlimmer sein. Oder besser.


    Beim letztjährigen Joker habe ich mir ein etwas interessanteres Beispiel angesehen, um die Leistung der Stream-API zu messen und die Vorgänge dort zu vertiefen. Nun, ein obligatorischer Verweis auf JMH : Es wird Ihnen helfen, nicht auf einen einfachen Rechen zu treten, wenn Sie die Leistung von JVM-Sprachen messen. Obwohl natürlich auch JMH nicht alle Ihre Probleme auf magische Weise lösen wird: Sie müssen immer noch nachdenken.


    Jetzt auch beliebt: