Warum ist if (variable1% variable2 == 0) ineffizient?

179

Ich bin neu in Java und habe letzte Nacht Code ausgeführt, und das hat mich wirklich gestört. Ich habe ein einfaches Programm erstellt, um alle X-Ausgänge in einer for-Schleife anzuzeigen, und ich habe einen massiven Leistungsabfall festgestellt, als ich den Modul als variable % variablevs variable % 5000oder so weiter verwendet habe. Kann mir jemand erklären, warum das so ist und was es verursacht? Also kann ich besser sein ...

Hier ist der "effiziente" Code (Entschuldigung, wenn ich ein bisschen falsche Syntax habe, bin ich gerade nicht mit dem Code auf dem Computer)

long startNum = 0;
long stopNum = 1000000000L;

for (long i = startNum; i <= stopNum; i++){
    if (i % 50000 == 0) {
        System.out.println(i);
    }
}

Hier ist der "ineffiziente Code"

long startNum = 0;
long stopNum = 1000000000L;
long progressCheck = 50000;

for (long i = startNum; i <= stopNum; i++){
    if (i % progressCheck == 0) {
        System.out.println(i);
    }
}

Wohlgemerkt, ich hatte eine Datumsvariable, um die Unterschiede zu messen, und sobald sie lang genug war, dauerte die erste 50 ms, während die andere 12 Sekunden oder so etwas dauerte. Möglicherweise müssen Sie die erhöhen stopNumoder verringern, progressCheckwenn Ihr PC effizienter als meiner ist oder was nicht.

Ich habe im Internet nach dieser Frage gesucht, aber ich kann keine Antwort finden. Vielleicht stelle ich sie einfach nicht richtig.

EDIT: Ich hatte nicht erwartet, dass meine Frage so beliebt ist, ich schätze alle Antworten. Ich habe für jede Hälfte der Zeit einen Benchmark durchgeführt, und der ineffiziente Code dauerte erheblich länger, 1/4 Sekunde gegenüber 10 Sekunden Geben oder Nehmen. Zugegeben, sie verwenden println, aber beide machen die gleiche Menge, daher würde ich mir nicht vorstellen, dass dies zu einer starken Verzerrung führen würde, zumal die Diskrepanz wiederholbar ist. Da ich neu in Java bin, werde ich die Stimmen vorerst entscheiden lassen, welche Antwort die beste ist. Ich werde versuchen, bis Mittwoch einen auszuwählen.

EDIT2: Ich werde heute Abend einen weiteren Test durchführen, bei dem anstelle des Moduls nur eine Variable inkrementiert wird. Wenn progressCheck erreicht ist, wird eine durchgeführt und diese Variable für eine dritte Option auf 0 zurückgesetzt.

EDIT3.5:

Ich habe diesen Code verwendet und unten zeige ich meine Ergebnisse. Vielen Dank an ALLE für die wunderbare Hilfe! Ich habe auch versucht, den kurzen Wert des langen mit dem Wert 0 zu vergleichen, sodass alle meine neuen Überprüfungen immer "65536" Mal durchgeführt werden, sodass sie in Wiederholungen gleich sind.

public class Main {


    public static void main(String[] args) {

        long startNum = 0;
        long stopNum = 1000000000L;
        long progressCheck = 65536;
        final long finalProgressCheck = 50000;
        long date;

        // using a fixed value
        date = System.currentTimeMillis();
        for (long i = startNum; i <= stopNum; i++) {
            if (i % 65536 == 0) {
                System.out.println(i);
            }
        }
        long final1 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();
        //using a variable
        for (long i = startNum; i <= stopNum; i++) {
            if (i % progressCheck == 0) {
                System.out.println(i);
            }
        }
        long final2 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();

        // using a final declared variable
        for (long i = startNum; i <= stopNum; i++) {
            if (i % finalProgressCheck == 0) {
                System.out.println(i);
            }
        }
        long final3 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();
        // using increments to determine progressCheck
        int increment = 0;
        for (long i = startNum; i <= stopNum; i++) {
            if (increment == 65536) {
                System.out.println(i);
                increment = 0;
            }
            increment++;

        }

        //using a short conversion
        long final4 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();
        for (long i = startNum; i <= stopNum; i++) {
            if ((short)i == 0) {
                System.out.println(i);
            }
        }
        long final5 = System.currentTimeMillis() - date;

                System.out.println(
                "\nfixed = " + final1 + " ms " + "\nvariable = " + final2 + " ms " + "\nfinal variable = " + final3 + " ms " + "\nincrement = " + final4 + " ms" + "\nShort Conversion = " + final5 + " ms");
    }
}

Ergebnisse:

  • fest = 874 ms (normalerweise um 1000 ms, aber schneller, da es eine Potenz von 2 ist)
  • variabel = 8590 ms
  • letzte Variable = 1944 ms (war ~ 1000 ms bei Verwendung von 50000)
  • Inkrement = 1904 ms
  • Kurzumwandlung = 679 ms

Kein Wunder, dass die Short Conversion aufgrund mangelnder Teilung 23% schneller war als der "schnelle" Weg. Das ist interessant zu bemerken. Wenn Sie alle 256 Mal (oder ungefähr dort) etwas zeigen oder vergleichen müssen, können Sie dies tun und verwenden

if ((byte)integer == 0) {'Perform progress check code here'}

EIN ENDGÜLTIGER INTERESSANTER HINWEIS: Die Verwendung des Moduls für die "endgültig deklarierte Variable" mit 65536 (keine hübsche Zahl) war halb so schnell (langsamer) als der feste Wert. Wo vorher war es Benchmarking in der Nähe der gleichen Geschwindigkeit.

Robert Cotterman
quelle
29
Ich habe tatsächlich das gleiche Ergebnis erzielt. Auf meinem Computer läuft die erste Schleife in ungefähr 1,5 Sekunden und die zweite in ungefähr 9 Sekunden. Wenn ich finalvor der progressCheckVariablen hinzufüge , laufen beide wieder mit der gleichen Geschwindigkeit. Das lässt mich glauben, dass der Compiler oder die JIT es schaffen, die Schleife zu optimieren, wenn sie weiß, dass dies progressCheckkonstant ist.
Marstran
24
Die Division durch eine Konstante kann leicht in eine Multiplikation mit der multiplikativen Inversen umgewandelt werden . Division durch eine Variable kann nicht. Und eine 32-Bit-Division ist schneller als eine 64-Bit-Division auf x86
phuclv
2
@phuclv Hinweis 32-Bit-Division ist hier kein Problem, es ist eine 64-Bit-
Restoperation
4
@ RobertCotterman Wenn Sie die Variable als endgültig deklarieren, erstellt der Compiler den gleichen Bytecode wie bei Verwendung der Konstante (Eclipse / Java 11) ((trotz Verwendung eines weiteren Speichersteckplatzes für die Variable))
user85421

Antworten:

139

Sie messen den OSR- Stub (On-Stack-Ersatz) .

OSR-Stub ist eine spezielle Version der kompilierten Methode, die speziell zum Übertragen der Ausführung vom interpretierten Modus in kompilierten Code während der Ausführung der Methode vorgesehen ist.

OSR-Stubs sind nicht so optimiert wie normale Methoden, da sie ein Frame-Layout benötigen, das mit dem interpretierten Frame kompatibel ist. Ich habe dies bereits in den folgenden Antworten gezeigt: 1 , 2 , 3 .

Ähnliches passiert auch hier. Während "ineffizienter Code" eine lange Schleife ausführt, wird die Methode speziell für den On-Stack-Austausch direkt innerhalb der Schleife kompiliert. Der Status wird vom interpretierten Frame an die OSR-kompilierte Methode übertragen, und dieser Status enthält eine progressChecklokale Variable. Zu diesem Zeitpunkt kann JIT die Variable nicht durch die Konstante ersetzen und daher bestimmte Optimierungen wie die Festigkeitsreduzierung nicht anwenden .

Insbesondere ist diese Mittel JIT ersetzen nicht ganzzahlige Division mit Multiplikation . (Siehe Warum verwendet GCC bei der Implementierung der Ganzzahldivision die Multiplikation mit einer seltsamen Zahl? Für den asm-Trick eines Compilers vor der Zeit, wenn der Wert nach Inlining / Konstanten-Propagation eine Konstante zur Kompilierungszeit ist, wenn diese Optimierungen aktiviert sind Ein ganzzahliges Literalrecht im %Ausdruck wird ebenfalls von optimiert gcc -O0, ähnlich wie hier, wo es vom JITer selbst in einem OSR-Stub optimiert wird.)

Wenn Sie dieselbe Methode jedoch mehrmals ausführen, führen der zweite und der nachfolgende Lauf den regulären Code (ohne OSR) aus, der vollständig optimiert ist. Hier ist ein Benchmark zum Nachweis der Theorie ( Benchmarking mit JMH ):

@State(Scope.Benchmark)
public class Div {

    @Benchmark
    public void divConst(Blackhole blackhole) {
        long startNum = 0;
        long stopNum = 100000000L;

        for (long i = startNum; i <= stopNum; i++) {
            if (i % 50000 == 0) {
                blackhole.consume(i);
            }
        }
    }

    @Benchmark
    public void divVar(Blackhole blackhole) {
        long startNum = 0;
        long stopNum = 100000000L;
        long progressCheck = 50000;

        for (long i = startNum; i <= stopNum; i++) {
            if (i % progressCheck == 0) {
                blackhole.consume(i);
            }
        }
    }
}

Und die Ergebnisse:

# Benchmark: bench.Div.divConst

# Run progress: 0,00% complete, ETA 00:00:16
# Fork: 1 of 1
# Warmup Iteration   1: 126,967 ms/op
# Warmup Iteration   2: 105,660 ms/op
# Warmup Iteration   3: 106,205 ms/op
Iteration   1: 105,620 ms/op
Iteration   2: 105,789 ms/op
Iteration   3: 105,915 ms/op
Iteration   4: 105,629 ms/op
Iteration   5: 105,632 ms/op


# Benchmark: bench.Div.divVar

# Run progress: 50,00% complete, ETA 00:00:09
# Fork: 1 of 1
# Warmup Iteration   1: 844,708 ms/op          <-- much slower!
# Warmup Iteration   2: 105,893 ms/op          <-- as fast as divConst
# Warmup Iteration   3: 105,601 ms/op
Iteration   1: 105,570 ms/op
Iteration   2: 105,475 ms/op
Iteration   3: 105,702 ms/op
Iteration   4: 105,535 ms/op
Iteration   5: 105,766 ms/op

Die allererste Iteration von divVarist in der Tat viel langsamer, da der OSR-Stub ineffizient kompiliert wurde. Sobald die Methode jedoch von Anfang an erneut ausgeführt wird, wird die neue, nicht eingeschränkte Version ausgeführt, die alle verfügbaren Compiler-Optimierungen nutzt.

Apangin
quelle
5
Ich zögere, darüber abzustimmen. Einerseits klingt es nach einer ausgeklügelten Art zu sagen: "Sie haben Ihren Benchmark durcheinander gebracht, etwas über JIT gelesen". Andererseits frage ich mich, warum Sie so sicher zu sein scheinen, dass OSR hier der wichtigste relevante Punkt war. Ich meine, eine (Mikro) Benchmark zu tun , die beinhaltet System.out.printlnfast wird zwangsläufig Müll Ergebnisse produzieren, und die Tatsache , dass beide Versionen gleich schnell muss nicht mit OSR alles tun , insbesondere , soweit ich das sagen kann ..
Marco13
2
(Ich bin neugierig und verstehe das gerne. Ich hoffe, die Kommentare stören nicht, könnten sie später löschen, aber :) Der Link 1ist etwas zweifelhaft - die leere Schleife könnte auch komplett wegoptimiert werden. Der zweite ist diesem ähnlicher. Aber auch hier ist nicht klar, warum Sie den Unterschied speziell dem OSR zuschreiben . Ich würde nur sagen: Irgendwann ist die Methode JITed und wird schneller. Nach meinem Verständnis bewirkt das OSR nur, dass die Verwendung des endgültigen, optimierten Codes (ungefähr) "auf den nächsten Optimierungsdurchlauf verschoben" wird. (Fortsetzung ...)
Marco13
1
(Fortsetzung :) Wenn Sie die Hotspot-Protokolle nicht speziell analysieren, können Sie nicht sagen, ob der Unterschied durch den Vergleich von JITed- und Un-JITed-Code oder durch den Vergleich von JITed- und OSR-Stub-Code verursacht wird. Und das können Sie sicherlich nicht mit Sicherheit sagen, wenn die Frage nicht den tatsächlichen Code oder einen vollständigen JMH-Benchmark enthält. Das Argument, dass der Unterschied durch OSR verursacht wird, klingt für mich unangemessen spezifisch (und "ungerechtfertigt"), verglichen mit der Aussage, dass er durch die GEG im Allgemeinen verursacht wird. (Nichts für ungut - ich frage mich nur ...)
Marco13
4
@ Marco13 gibt es eine einfache Heuristik: Ohne die Aktivität der JIT hätte jede %Operation das gleiche Gewicht, da eine optimierte Ausführung nur möglich ist, wenn ein Optimierer tatsächlich arbeitet. Die Tatsache, dass eine Schleifenvariante erheblich schneller als die andere ist, beweist das Vorhandensein eines Optimierers und beweist ferner, dass es nicht gelungen ist, eine der Schleifen im gleichen Maße wie die andere zu optimieren (innerhalb derselben Methode!). Da diese Antwort die Fähigkeit beweist, beide Schleifen im gleichen Maße zu optimieren, muss etwas vorhanden sein, das die Optimierung behindert. Und das ist OSR in 99,9% aller Fälle
Holger
4
@ Marco13 Das war eine "fundierte Vermutung", die auf dem Wissen von HotSpot Runtime und der Erfahrung bei der Analyse ähnlicher Probleme basiert. Eine so lange Schleife könnte kaum anders als mit OSR kompiliert werden, insbesondere in einem einfachen handgemachten Benchmark. Wenn OP den vollständigen Code veröffentlicht hat, kann ich die Argumentation nur noch einmal bestätigen, indem ich den Code mit ausführe -XX:+PrintCompilation -XX:+TraceNMethodInstalls.
Apangin
42

Im Anschluss an den @ phuclv- Kommentar habe ich den von JIT 1 generierten Code überprüft. Die Ergebnisse lauten wie folgt:

für variable % 5000(Division durch Konstante):

mov     rax,29f16b11c6d1e109h
imul    rbx
mov     r10,rbx
sar     r10,3fh
sar     rdx,0dh
sub     rdx,r10
imul    r10,rdx,0c350h    ; <-- imul
mov     r11,rbx
sub     r11,r10
test    r11,r11
jne     1d707ad14a0h

für variable % variable:

mov     rax,r14
mov     rdx,8000000000000000h
cmp     rax,rdx
jne     22ccce218edh
xor     edx,edx
cmp     rbx,0ffffffffffffffffh
je      22ccce218f2h
cqo
idiv    rax,rbx           ; <-- idiv
test    rdx,rdx
jne     22ccce218c0h

Da die Division immer länger dauert als die Multiplikation, ist das letzte Code-Snippet weniger performant.

Java-Version:

java version "11" 2018-09-25
Java(TM) SE Runtime Environment 18.9 (build 11+28)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11+28, mixed mode)

1 - Verwendete VM-Optionen: -XX:+UnlockDiagnosticVMOptions -XX:CompileCommand=print,src/java/Main.main

Oleksandr Pyrohov
quelle
14
Eine Größenordnung für "langsamer" für x86_64 anzugeben: imul3 Zyklen, idivliegt zwischen 30 und 90 Zyklen. Die Ganzzahldivision ist also zwischen 10x und 30x langsamer als die Ganzzahlmultiplikation.
Matthieu M.
2
Können Sie erklären, was dies alles für Leser bedeutet, die interessiert sind, aber keinen Assembler sprechen?
Nico Haase
7
@NicoHaase Die beiden kommentierten Zeilen sind die einzigen wichtigen. Im ersten Abschnitt führt der Code eine Ganzzahlmultiplikation durch, während im zweiten Abschnitt der Code eine Ganzzahldivision durchführt. Wenn Sie darüber nachdenken, Multiplikation und Division von Hand durchzuführen, führen Sie beim Multiplizieren normalerweise eine Reihe kleiner Multiplikationen und dann eine große Menge von Additionen durch, aber Division ist eine kleine Division, eine kleine Multiplikation, eine Subtraktion und eine Wiederholung. Die Division ist langsam, weil Sie im Wesentlichen eine Reihe von Multiplikationen durchführen.
MBraedley
4
@ MBraedley danke für Ihre Eingabe, aber eine solche Erklärung sollte der Antwort selbst hinzugefügt und nicht im Kommentarbereich versteckt werden
Nico Haase
6
@ MBraedley: Genauer gesagt ist die Multiplikation in einer modernen CPU schnell, da die Teilprodukte unabhängig sind und somit separat berechnet werden können, während jede Stufe einer Division von den vorhergehenden Stufen abhängt.
Supercat
26

Wie andere angemerkt haben, erfordert die allgemeine Moduloperation eine Division. In einigen Fällen kann die Division (durch den Compiler) durch eine Multiplikation ersetzt werden. Beide können jedoch im Vergleich zur Addition / Subtraktion langsam sein. Daher kann die beste Leistung von etwas in dieser Richtung erwartet werden:

long progressCheck = 50000;

long counter = progressCheck;

for (long i = startNum; i <= stopNum; i++){
    if (--counter == 0) {
        System.out.println(i);
        counter = progressCheck;
    }
}

(Als kleinen Optimierungsversuch verwenden wir hier einen Abwärtszähler vor der Dekrementierung, da bei vielen Architekturen im Vergleich zu 0unmittelbar nach einer arithmetischen Operation genau 0 Befehle / CPU-Zyklen kosten, da die Flags der ALU durch die vorhergehende Operation bereits entsprechend gesetzt sind. Eine anständige Optimierung Der Compiler führt diese Optimierung jedoch automatisch durch, selbst wenn Sie schreiben if (counter++ == 50000) { ... counter = 0; }.)

Beachten Sie, dass Sie den Modul oft nicht wirklich wollen / brauchen, weil Sie wissen, dass Ihr Schleifenzähler ( i) oder was auch immer immer nur um 1 erhöht wird, und Sie sich wirklich nicht um den tatsächlichen Rest kümmern, den der Modul Ihnen gibt, sehen Sie einfach wenn der um eins erhöhte Zähler einen Wert erreicht.

Ein weiterer "Trick" besteht darin, Zweierpotenzwerte / -grenzen zu verwenden, z progressCheck = 1024;. Der Modul einer Zweierpotenz kann schnell bitweise berechnet werden and, d if ( (i & (1024-1)) == 0 ) {...}. H. Dies sollte auch ziemlich schnell sein und kann bei einigen Architekturen die counteroben genannten Anforderungen übertreffen .

JimmyB
quelle
3
Ein intelligenter Compiler würde die Schleifen hier invertieren. Oder Sie könnten das in der Quelle tun. Der if()Körper wird zu einem Körper der äußeren Schleife, und das Material außerhalb des Körpers wird zu einem Körper der if()inneren Schleife, der für min(progressCheck, stopNum-i)Iterationen ausgeführt wird. Zu Beginn und jedes Mal counter, wenn 0 erreicht ist, müssen Sie long next_stop = i + min(progressCheck, stopNum-i);eine for(; i< next_stop; i++) {}Schleife einrichten . In diesem Fall ist diese innere Schleife leer und sollte hoffentlich vollständig optimiert werden. Sie können dies in der Quelle tun und es dem JITer leicht machen, indem Sie Ihre Schleife auf i + = 50k reduzieren.
Peter Cordes
2
Aber ja, im Allgemeinen ist ein Down-Counter eine gute, effiziente Technik für Sachen vom Typ Fizzbuzz / Progresscheck.
Peter Cordes
Ich habe meine Frage ergänzt und ein Inkrement durchgeführt. Das --counterist genauso schnell wie meine Inkrement-Version, aber weniger Code. Außerdem war es 1 niedriger als es sein sollte. Ich bin gespannt, ob es sein sollte counter--, um die genaue Nummer zu erhalten, die Sie wollen , nicht, dass es ein großer Unterschied ist
Robert Cotterman
@PeterCordes Ein intelligenter Compiler würde nur die Zahlen drucken, überhaupt keine Schleife. (Ich denke, einige nur geringfügig trivialere Benchmarks haben vor vielleicht 10 Jahren auf diese Weise versagt.)
Peter - Reinstate Monica
2
@ RobertCotterman Ja, --counterist um eins aus. counter--gibt Ihnen genau die progressCheckAnzahl der Iterationen (oder Sie könnten progressCheck = 50001;natürlich einstellen ).
JimmyB
4

Ich bin auch überrascht, wie gut die oben genannten Codes funktionieren. Es geht um die Zeit, die der Compiler für die Ausführung des Programms gemäß der deklarierten Variablen benötigt. Im zweiten (ineffizienten) Beispiel:

for (long i = startNum; i <= stopNum; i++) {
    if (i % progressCheck == 0) {
        System.out.println(i)
    }
}

Sie führen die Moduloperation zwischen zwei Variablen aus. Hier muss der Compiler jedes Mal nach jeder Iteration den Wert von überprüfen stopNumund progressCheckzu dem spezifischen Speicherblock gehen, der sich für diese Variablen befindet, da es sich um eine Variable handelt und sich ihr Wert möglicherweise ändert.

Aus diesem Grund ging der Compiler nach jeder Iteration zum Speicherort, um den neuesten Wert der Variablen zu überprüfen. Daher konnte der Compiler zum Zeitpunkt der Kompilierung keinen effizienten Bytecode erstellen.

Im ersten Codebeispiel führen Sie einen Moduloperator zwischen einer Variablen und einem konstanten numerischen Wert aus, der sich innerhalb der Ausführung nicht ändert, und der Compiler muss den Wert dieses numerischen Werts nicht vom Speicherort aus überprüfen. Aus diesem Grund konnte der Compiler effizienten Bytecode erstellen. Wenn Sie erklären progressCheckals eine finaloder als final staticVariable dann zum Zeitpunkt der Laufzeit / Kompilierung-Compiler weiß , dass es eine letzte Variable ist und sein Wert wird nicht dann den Compiler ersetzen ändern progressCheckmit 50000in Code:

for (long i = startNum; i <= stopNum; i++) {
    if (i % 50000== 0) {
        System.out.println(i)
    }
}

Jetzt können Sie sehen, dass dieser Code auch wie das erste (effiziente) Codebeispiel aussieht. Die Leistung des ersten Codes und wie oben erwähnt, funktioniert effizient. Es wird keinen großen Unterschied in der Ausführungszeit eines der beiden Codebeispiele geben.

Bishal Dubey
quelle
1
Es gibt einen RIESIGEN Unterschied, obwohl ich die Operation eine Billion Mal durchgeführt habe, so dass über 1 Billion Operationen 89% Zeit gespart wurden, um den "effizienten" Code auszuführen. Wohlgemerkt, wenn Sie es nur ein paar tausend Mal tun und über einen so kleinen Unterschied sprechen, ist es wahrscheinlich keine große Sache. Ich meine über 1000 Operationen würde es Ihnen 1 Millionstel von 7 Sekunden sparen.
Robert Cotterman
1
@Bishal Dubey "Es wird keinen großen Unterschied in der Ausführungszeit beider Codes geben." Hast du die Frage gelesen?
Grant Foster
„Deshalb nach jeder Iteration Compiler auf die Speicherstelle ging den letzten Wert der Variablen zu überprüfen“ - Es sei denn , die Variable deklariert wurde volatiledie ‚Compiler‘ wird nicht seinen Wert aus dem RAM immer und immer wieder lesen.
JimmyB