Dlaczego jeśli (zmienna 1% zmienna 2 == 0) jest nieefektywna?

179

Jestem nowy w Javie i zeszłej nocy uruchomiłem jakiś kod, co mnie bardzo niepokoiło. Budowałem prosty program do wyświetlania każdego wyjścia X w pętli for i zauważyłem MASYWNY spadek wydajności, kiedy użyłem modułu jako variable % variablevs variable % 5000lub co tam. Czy ktoś może mi wyjaśnić, dlaczego to jest i co go powoduje? Więc mogę być lepszy ...

Oto „wydajny” kod (przepraszam, jeśli mam trochę niepoprawnej składni, nie jestem teraz na komputerze z kodem)

long startNum = 0;
long stopNum = 1000000000L;

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

Oto „nieefektywny kod”

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

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

Pamiętaj, że miałem zmienną daty do pomiaru różnic, a gdy stała się wystarczająco długa, pierwsza zajęła 50 ms, a druga 12 sekund lub coś w tym rodzaju. Być może będziesz musiał zwiększyć stopNumlub zmniejszyć, progressCheckjeśli Twój komputer jest bardziej wydajny niż mój, czy co.

Szukałem tego pytania w Internecie, ale nie mogę znaleźć odpowiedzi, może po prostu nie zadaję tego poprawnie.

EDYCJA: Nie spodziewałem się, że moje pytanie będzie tak popularne, doceniam wszystkie odpowiedzi. Przeprowadziłem test porównawczy w każdej połowie czasu, a nieefektywny kod trwał znacznie dłużej, 1/4 sekundy w porównaniu do 10 sekund. To prawda, że ​​używają println, ale obaj robią to samo, więc nie wyobrażam sobie, że to by to wypaczyło, zwłaszcza że rozbieżność jest powtarzalna. Jeśli chodzi o odpowiedzi, ponieważ jestem nowy w Javie, pozwolę głosom zdecydować, która odpowiedź jest najlepsza. Spróbuję wybrać jeden do środy.

EDYCJA 2: Dziś wieczorem zrobię kolejny test, w którym zamiast modułu, po prostu inkrementuje zmienną, a kiedy osiągnie progressCheck, wykona jedną, a następnie zresetuje tę zmienną do 0. dla trzeciej opcji.

EDYCJA 3.5:

Użyłem tego kodu, a poniżej pokażę moje wyniki. Dziękujemy wszystkim za wspaniałą pomoc! Próbowałem także porównać krótką wartość długiego z 0, więc wszystkie moje nowe kontrole zdarzają się zawsze „65536” razy, co czyni je równymi w powtórzeniach.

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

Wyniki:

  • naprawiono = 874 ms (zwykle około 1000 ms, ale szybciej, ponieważ jest to moc 2)
  • zmienna = 8590 ms
  • zmienna końcowa = 1944 ms (było ~ 1000 ms przy użyciu 50000)
  • przyrost = 1904 ms
  • Krótka konwersja = 679 ms

Nic dziwnego, ze względu na brak podziału krótka konwersja była o 23% szybsza niż „szybka”. Warto to zauważyć. Jeśli chcesz coś pokazywać lub porównywać co 256 razy (lub mniej więcej tam), możesz to zrobić i użyć

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

JEDNA KOŃCOWA UWAGA: użycie modułu w „Ostatecznej deklarowanej zmiennej” z wartością 65536 (niezbyt ładną liczbą) było o połowę mniejsze (wolniejsze) niż ustalona wartość. Gdzie wcześniej było to porównywanie z tą samą prędkością.

Robert Cotterman
źródło
29
Właściwie mam ten sam wynik. Na mojej maszynie pierwsza pętla działa za około 1,5 sekundy, a druga za około 9 sekund. Jeśli dodam finalprzed progressCheckzmienną, oba biegną ponownie z tą samą prędkością. To prowadzi mnie do przekonania, że ​​kompilatorowi lub JITowi udaje się zoptymalizować pętlę, gdy wie, że progressCheckjest stała.
marstran
24
Dzielenie przez stałą można łatwo przekształcić w mnożenie przez odwrotność mnożenia . Dzielenie według zmiennej nie może. A podział 32-bitowy jest szybszy niż podział 64-bitowy na x86
phuclv
2
@ phuclv note Podział 32-bitowy nie jest tutaj problemem, w obu przypadkach jest to operacja 64-bitowa
user85421 28.0119
4
@RobertCotterman, jeśli zadeklarujesz zmienną jako końcową, kompilator utworzy ten sam kod bajtowy, jak przy użyciu stałej (eclipse / Java 11) ((pomimo użycia jeszcze jednego gniazda pamięci dla zmiennej))
user85421 28.01.2019

Odpowiedzi:

139

Jesteś pomiaru (wymianę na stosie) OSR niedopałek.

Kod OSR jest specjalną wersją skompilowanej metody przeznaczonej specjalnie do przenoszenia wykonywania z trybu interpretowanego do skompilowanego kodu podczas działania metody.

Kody OSR nie są tak zoptymalizowane jak zwykłe metody, ponieważ wymagają układu zgodnego z interpretowaną ramką. Pokazałem to już w następujących odpowiedziach: 1 , 2 , 3 .

Podobnie dzieje się tutaj. Podczas gdy „niewydajny kod” uruchamia długą pętlę, metoda jest kompilowana specjalnie dla zamiany stosu bezpośrednio w pętli. Stan jest przenoszony z interpretowanej ramki do metody skompilowanej przez OSR, a ten stan obejmuje progressCheckzmienną lokalną. W tym momencie JIT nie może zastąpić zmiennej stałą, a zatem nie może zastosować pewnych optymalizacji, takich jak redukcja siły .

W szczególności środki JIT nie zastępuje całkowitą podział z mnożenia . (Zobacz Dlaczego GCC używa mnożenia przez dziwną liczbę w implementacji dzielenia liczb całkowitych? Dla sztuczki asm z kompilatora z wyprzedzeniem, gdy wartość jest stałą czasu kompilacji po wstawianiu / stałej propagacji, jeśli te optymalizacje są włączone Również dosłownie liczba całkowita w %wyrażeniu jest optymalizowana przez gcc -O0, podobnie jak tutaj, gdzie jest zoptymalizowana przez JITer nawet w odcinku OSR.)

Jeśli jednak uruchomisz tę samą metodę kilka razy, drugie i kolejne uruchomienia spowodują wykonanie zwykłego (nie OSR) kodu, który jest w pełni zoptymalizowany. Oto punkt odniesienia dla udowodnienia teorii ( test porównawczy przy użyciu 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);
            }
        }
    }
}

A wyniki:

# 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

Pierwsza iteracja divVarjest rzeczywiście znacznie wolniejsza z powodu nieefektywnie skompilowanego kodu OSR. Ale gdy tylko metoda uruchomi się od nowa, uruchamiana jest nowa, nieograniczona wersja, która wykorzystuje wszystkie dostępne optymalizacje kompilatora.

apangin
źródło
5
Waham się zagłosować w tej sprawie. Z jednej strony brzmi to jak skomplikowany sposób powiedzenia „Zepsułeś swój test porównawczy, poczytałeś coś o JIT”. Z drugiej strony zastanawiam się, dlaczego wydajesz się być tak pewien, że OSR był tutaj najważniejszym punktem. To znaczy, robi (mikro) wzorzec, który wiąże System.out.printlnsię niemal zawsze rezultaty śmieci, oraz fakt, że obie wersje są równie szybko, nie trzeba robić nic z OSR, w szczególności , o ile mogę powiedzieć ..
Marco13
2
(Jestem ciekawy i lubię to rozumieć. Mam nadzieję, że komentarze nie będą niepokojące, mogą je później usunąć, ale:) Link 1jest nieco wątpliwy - pustą pętlę można również całkowicie zoptymalizować. Drugi jest bardziej podobny do tego. Ale znowu nie jest jasne, dlaczego przypisujesz różnicę konkretnie OSR . Powiedziałbym tylko: w pewnym momencie metoda jest JITed i staje się szybsza. O ile mi wiadomo, OSR powoduje jedynie, że użycie końcowego, zoptymalizowanego kodu zostanie (z grubsza) ~ „odroczone do następnego przejścia optymalizacji”. (ciąg dalszy ...)
Marco13
1
(ciąg dalszy :) O ile nie analizujesz konkretnie dzienników hotspotów, nie możesz powiedzieć, czy różnica jest spowodowana przez porównanie kodu JITed i nie JITed, czy przez porównanie JITed i kodu pośredniczącego OSR. I na pewno nie można powiedzieć, że na pewno, gdy pytanie nie zawiera prawdziwy kod lub kompletny wzorzec JMH. Argumentowanie, że różnica jest spowodowana przez OSR, brzmi dla mnie nieodpowiednio (i „nieuzasadniony”) w porównaniu do stwierdzenia, że ​​jest to spowodowane przez JIT w ogóle. (Bez obrazy - po prostu się zastanawiam ...)
Marco13
4
@ Marco13 jest prosta heurystyka: bez działania JIT każda %operacja miałaby taką samą wagę, ponieważ zoptymalizowane wykonanie jest możliwe tylko, jeśli optymalizator wykonałby rzeczywistą pracę. Zatem fakt, że jeden wariant pętli jest znacznie szybszy od drugiego, dowodzi obecności optymalizatora, a ponadto dowodzi, że nie udało mu się zoptymalizować jednej z pętli w tym samym stopniu co drugi (w ramach tej samej metody!). Ponieważ ta odpowiedź dowodzi możliwości optymalizacji obu pętli w tym samym stopniu, musi istnieć coś, co utrudnia optymalizację. I to jest OSR w 99,9% wszystkich przypadków
Holger
4
@ Marco13 To było „wykształcone przypuszczenie” oparte na wiedzy o środowisku uruchomieniowym HotSpot i doświadczeniu w analizowaniu podobnych problemów wcześniej. Tak długiej pętli trudno byłoby skompilować w inny sposób niż OSR, zwłaszcza w prostym, ręcznie wykonanym teście porównawczym. Teraz, kiedy OP opublikuje pełny kod, mogę potwierdzić to rozumowanie tylko raz, uruchamiając kod za pomocą -XX:+PrintCompilation -XX:+TraceNMethodInstalls.
apangin
42

W odpowiedzi na komentarz @phuclv sprawdziłem kod wygenerowany przez JIT 1 , wyniki są następujące:

dla variable % 5000(dzielenie przez stałą):

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

dla 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

Ponieważ dzielenie zawsze trwa dłużej niż mnożenie, ostatni fragment kodu jest mniej wydajny.

Wersja Java:

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 - Zastosowane opcje VM: -XX:+UnlockDiagnosticVMOptions -XX:CompileCommand=print,src/java/Main.main

Oleksandr Pyrohov
źródło
14
Aby podać rząd wielkości „wolniej”, dla x86_64: imulwynosi 3 cykle, idivwynosi od 30 do 90 cykli. Tak więc dzielenie liczb całkowitych jest od 10x do 30x wolniejsze niż mnożenie liczb całkowitych.
Matthieu M.
2
Czy możesz wyjaśnić, co to wszystko oznacza dla czytelników, którzy są zainteresowani, ale nie mówią asemblera?
Nico Haase
7
@NicoHaase Dwie skomentowane linie są jedynymi ważnymi. W pierwszej sekcji kod wykonuje mnożenie liczb całkowitych, podczas gdy w drugiej sekcji kod wykonuje dzielenie liczb całkowitych. Jeśli myślisz o ręcznym pomnożeniu i dzieleniu, podczas mnożenia zwykle robisz masę małych mnożeń, a następnie jeden duży zestaw dodatków, ale dzielenie jest małym dzieleniem, małym mnożeniem, odejmowaniem i powtarzaniem. Podział jest powolny, ponieważ zasadniczo robisz wiele mnożenia.
MBraedley,
4
@MBraedley dziękuję za Twój wkład, ale takie wyjaśnienie należy dodać do samej odpowiedzi i nie powinno być ukryte w sekcji komentarzy
Nico Haase
6
@MBraedley: Mówiąc bardziej szczegółowo, mnożenie w nowoczesnym procesorze jest szybkie, ponieważ produkty częściowe są niezależne i dlatego mogą być obliczane osobno, podczas gdy każdy etap podziału zależy od poprzednich etapów.
supercat
26

Jak zauważyli inni, ogólne działanie modułu wymaga podziału. W niektórych przypadkach podział można zastąpić (przez kompilator) mnożeniem. Ale oba mogą być powolne w porównaniu do dodawania / odejmowania. Stąd, najlepszej wydajności można się spodziewać po czymś takim:

long progressCheck = 50000;

long counter = progressCheck;

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

(Jako drobną próbę optymalizacji używamy tutaj licznika wstępnego zmniejszania licznika wstępnego, ponieważ na wielu architekturach porównanie do 0natychmiast po operacji arytmetycznej kosztuje dokładnie 0 instrukcji / cykli procesora, ponieważ flagi ALU są już odpowiednio ustawione przez poprzednią operację. Przyzwoita optymalizacja kompilator wykona tę optymalizację automatycznie, nawet jeśli napiszesz if (counter++ == 50000) { ... counter = 0; }.)

Zauważ, że często tak naprawdę nie chcesz / potrzebujesz modułu, ponieważ wiesz, że twój licznik pętli ( i) lub cokolwiek jest zwiększany tylko o 1, i naprawdę nie przejmujesz się faktyczną pozostałą wartością modułu, po prostu zobacz jeśli licznik rosnący o jeden osiągnie pewną wartość.

Inną „sztuczką” jest użycie potęgi dwóch wartości / limitów, np progressCheck = 1024;. Moduł potęgi dwóch można szybko obliczyć bitowo and, tj if ( (i & (1024-1)) == 0 ) {...}. To również powinno być dość szybkie i może w niektórych architekturach przewyższyć counterpowyższe.

JimmyB
źródło
3
Inteligentny kompilator odwróciłby tutaj pętle. Lub możesz to zrobić w źródle. if()Ciało staje się ciałem zewnętrznej pętli, a materiał na zewnątrz if()się wewnętrzny korpus która rozciąga się w min(progressCheck, stopNum-i)iteracji. Tak więc na początku i za każdym razem, gdy counterosiągnie 0, musisz long next_stop = i + min(progressCheck, stopNum-i);skonfigurować for(; i< next_stop; i++) {}pętlę. W tym przypadku ta wewnętrzna pętla jest pusta i powinna być całkowicie zoptymalizowana, możesz to zrobić w źródle i ułatwić JITerowi, zmniejszając swoją pętlę do i + = 50k.
Peter Cordes
2
Ale tak, ogólnie rzecz biorąc, licznik spadków jest dobrą skuteczną techniką dla rzeczy typu fizzbuzz / progresscheck.
Peter Cordes
Dodałem do mojego pytania i zrobiłem przyrost, --counterjest tak samo szybki jak moja wersja przyrostowa, ale mniej kodu. Również był o 1 niższy niż powinien być, jestem ciekawy, czy powinno być counter--uzyskanie dokładnej liczby, którą chcesz , nie dlatego, że to duża różnica
Robert Cotterman
@PeterCordes Inteligentny kompilator po prostu wydrukuje liczby, bez żadnej pętli. (Myślę, że niektóre nieco bardziej trywialne testy porównawcze zaczęły zawodzić w ten sposób może 10 lat temu.)
Peter - Przywróć Monikę
2
@RobertCotterman Tak, --counterjest wyłączony o jeden. counter--poda ci dokładnie progressCheckliczbę iteracji (lub możesz ustawić progressCheck = 50001;oczywiście).
JimmyB
4

Jestem również zaskoczony, widząc wydajność powyższych kodów. Chodzi o czas potrzebny kompilatorowi na uruchomienie programu zgodnie z zadeklarowaną zmienną. W drugim (nieefektywnym) przykładzie:

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

Wykonujesz operację modułu między dwiema zmiennymi. Tutaj kompilator musi sprawdzać wartość stopNumi progressCheckprzejść do określonego bloku pamięci dla tych zmiennych za każdym razem po każdej iteracji, ponieważ jest to zmienna, a jej wartość może ulec zmianie.

Dlatego po każdym kompilatorze iteracji udaje się do miejsca w pamięci, aby sprawdzić najnowszą wartość zmiennych. Dlatego w czasie kompilacji kompilator nie był w stanie utworzyć wydajnego kodu bajtowego.

W pierwszym przykładzie kodu wykonujesz operator modułu między zmienną a stałą wartością liczbową, która nie ulegnie zmianie w trakcie wykonywania, a kompilator nie musi sprawdzać wartości tej wartości liczbowej z miejsca w pamięci. Dlatego kompilator był w stanie stworzyć wydajny bajtowy kod. Jeśli zadeklarujesz progressCheckpostaci finallub jako final staticzmiennej następnie w czasie run-time / kompilacji kompilator wie, że jest to zmienna, a jej ostateczna wartość nie ulegnie zmianie, a następnie kompilator zastąpi progressCheckz 50000kodem:

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

Teraz możesz zobaczyć, że ten kod również wygląda jak pierwszy (wydajny) przykład kodu. Wydajność pierwszego kodu i, jak wspomniano powyżej, oba kody będą działały wydajnie. W żadnym z przykładów kodu nie będzie dużej różnicy w czasie wykonywania.

Bishal Dubey
źródło
1
Jest OGROMNA różnica, chociaż robiłem tę operację trylion razy, więc ponad 1 bilion operacji zaoszczędziłem 89% czasu na wykonanie „wydajnego” kodu. pamiętam, że jeśli robisz to tylko kilka tysięcy razy, mówiłeś tak małą różnicę, to chyba nie jest wielka sprawa. mam na myśli ponad 1000 operacji, to zaoszczędziłoby 1 milionową część 7 sekund.
Robert Cotterman
1
@Bishal Dubey „Nie będzie dużej różnicy w czasie wykonania obu kodów”. Czy przeczytałeś pytanie?
Grant Foster
„Dlatego po każdym kompilatorze iteracji udał się do miejsca w pamięci, aby sprawdzić najnowszą wartość zmiennych” - chyba że zmienna została zadeklarowana, volatile„kompilator” nie będzie odczytywał swojej wartości z pamięci RAM raz po raz.
JimmyB