Dlaczego 2 * (i * i) jest szybszy niż 2 * i * i w Javie?


855

Uruchomienie następującego programu Java zajmuje średnio od 0,50 sekundy do 0,55 sekundy:

public static void main(String[] args) {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += 2 * (i * i);
    }
    System.out.println((double) (System.nanoTime() - startTime) / 1000000000 + " s");
    System.out.println("n = " + n);
}

Jeśli mogę wymienić 2 * (i * i)z 2 * i * i, trwa między 0,60 i 0,65 sek do uruchomienia. Dlaczego?

Uruchomiłem każdą wersję programu 15 razy, naprzemiennie między nimi. Oto wyniki:

 2*(i*i)  |  2*i*i
----------+----------
0.5183738 | 0.6246434
0.5298337 | 0.6049722
0.5308647 | 0.6603363
0.5133458 | 0.6243328
0.5003011 | 0.6541802
0.5366181 | 0.6312638
0.515149  | 0.6241105
0.5237389 | 0.627815
0.5249942 | 0.6114252
0.5641624 | 0.6781033
0.538412  | 0.6393969
0.5466744 | 0.6608845
0.531159  | 0.6201077
0.5048032 | 0.6511559
0.5232789 | 0.6544526

Najszybsza seria 2 * i * itrwała dłużej niż najwolniejsza seria 2 * (i * i). Gdyby mieli taką samą wydajność, prawdopodobieństwo takiego zdarzenia byłoby mniejsze niż 1/2^15 * 100% = 0.00305%.


5
Otrzymuję podobne wyniki (nieco inne liczby, ale zdecydowanie zauważalna i spójna luka, zdecydowanie więcej niż błąd próbkowania)
Krease


3
@ Krease Dobrze, że złapałeś mój błąd. Według nowego testu, który prowadziłem, 2 * i * ijest on wolniejszy. Spróbuję również biegać z Graalem.
Jorn Vernee

5
@nullpointer Aby naprawdę dowiedzieć się, dlaczego jedno jest szybsze od drugiego, musielibyśmy uzyskać wykresy demontażu lub Ideal dla tych metod. Asembler jest bardzo denerwujący, gdy próbuję to rozgryźć, więc próbuję uzyskać kompilację debugowania OpenJDK, która może generować ładne wykresy.
Jorn Vernee,

4
Możesz zmienić nazwę swojego pytania na „ Dlaczego jest i * i * 2szybszy niż 2 * i * i? ”, Aby zwiększyć jasność, że problem występuje w kolejności operacji.
Cœur

Odpowiedzi:


1202

Istnieje niewielka różnica w kolejności kodu bajtowego.

2 * (i * i):

     iconst_2
     iload0
     iload0
     imul
     imul
     iadd

vs 2 * i * i:

     iconst_2
     iload0
     imul
     iload0
     imul
     iadd

Na pierwszy rzut oka nie powinno to mieć znaczenia; jeśli cokolwiek, druga wersja jest bardziej optymalna, ponieważ wykorzystuje o jedno miejsce mniej.

Musimy więc zagłębić się w niższy poziom (JIT) 1 .

Pamiętaj, że JIT bardzo agresywnie rozwija małe pętle. Rzeczywiście obserwujemy 16-krotne rozwijanie się 2 * (i * i)przypadku:

030   B2: # B2 B3 <- B1 B2  Loop: B2-B2 inner main of N18 Freq: 1e+006
030     addl    R11, RBP    # int
033     movl    RBP, R13    # spill
036     addl    RBP, #14    # int
039     imull   RBP, RBP    # int
03c     movl    R9, R13 # spill
03f     addl    R9, #13 # int
043     imull   R9, R9  # int
047     sall    RBP, #1
049     sall    R9, #1
04c     movl    R8, R13 # spill
04f     addl    R8, #15 # int
053     movl    R10, R8 # spill
056     movdl   XMM1, R8    # spill
05b     imull   R10, R8 # int
05f     movl    R8, R13 # spill
062     addl    R8, #12 # int
066     imull   R8, R8  # int
06a     sall    R10, #1
06d     movl    [rsp + #32], R10    # spill
072     sall    R8, #1
075     movl    RBX, R13    # spill
078     addl    RBX, #11    # int
07b     imull   RBX, RBX    # int
07e     movl    RCX, R13    # spill
081     addl    RCX, #10    # int
084     imull   RCX, RCX    # int
087     sall    RBX, #1
089     sall    RCX, #1
08b     movl    RDX, R13    # spill
08e     addl    RDX, #8 # int
091     imull   RDX, RDX    # int
094     movl    RDI, R13    # spill
097     addl    RDI, #7 # int
09a     imull   RDI, RDI    # int
09d     sall    RDX, #1
09f     sall    RDI, #1
0a1     movl    RAX, R13    # spill
0a4     addl    RAX, #6 # int
0a7     imull   RAX, RAX    # int
0aa     movl    RSI, R13    # spill
0ad     addl    RSI, #4 # int
0b0     imull   RSI, RSI    # int
0b3     sall    RAX, #1
0b5     sall    RSI, #1
0b7     movl    R10, R13    # spill
0ba     addl    R10, #2 # int
0be     imull   R10, R10    # int
0c2     movl    R14, R13    # spill
0c5     incl    R14 # int
0c8     imull   R14, R14    # int
0cc     sall    R10, #1
0cf     sall    R14, #1
0d2     addl    R14, R11    # int
0d5     addl    R14, R10    # int
0d8     movl    R10, R13    # spill
0db     addl    R10, #3 # int
0df     imull   R10, R10    # int
0e3     movl    R11, R13    # spill
0e6     addl    R11, #5 # int
0ea     imull   R11, R11    # int
0ee     sall    R10, #1
0f1     addl    R10, R14    # int
0f4     addl    R10, RSI    # int
0f7     sall    R11, #1
0fa     addl    R11, R10    # int
0fd     addl    R11, RAX    # int
100     addl    R11, RDI    # int
103     addl    R11, RDX    # int
106     movl    R10, R13    # spill
109     addl    R10, #9 # int
10d     imull   R10, R10    # int
111     sall    R10, #1
114     addl    R10, R11    # int
117     addl    R10, RCX    # int
11a     addl    R10, RBX    # int
11d     addl    R10, R8 # int
120     addl    R9, R10 # int
123     addl    RBP, R9 # int
126     addl    RBP, [RSP + #32 (32-bit)]   # int
12a     addl    R13, #16    # int
12e     movl    R11, R13    # spill
131     imull   R11, R13    # int
135     sall    R11, #1
138     cmpl    R13, #999999985
13f     jl     B2   # loop end  P=1.000000 C=6554623.000000

Widzimy, że na stosie znajduje się 1 rejestr „rozlany”.

A dla 2 * i * iwersji:

05a   B3: # B2 B4 <- B1 B2  Loop: B3-B2 inner main of N18 Freq: 1e+006
05a     addl    RBX, R11    # int
05d     movl    [rsp + #32], RBX    # spill
061     movl    R11, R8 # spill
064     addl    R11, #15    # int
068     movl    [rsp + #36], R11    # spill
06d     movl    R11, R8 # spill
070     addl    R11, #14    # int
074     movl    R10, R9 # spill
077     addl    R10, #16    # int
07b     movdl   XMM2, R10   # spill
080     movl    RCX, R9 # spill
083     addl    RCX, #14    # int
086     movdl   XMM1, RCX   # spill
08a     movl    R10, R9 # spill
08d     addl    R10, #12    # int
091     movdl   XMM4, R10   # spill
096     movl    RCX, R9 # spill
099     addl    RCX, #10    # int
09c     movdl   XMM6, RCX   # spill
0a0     movl    RBX, R9 # spill
0a3     addl    RBX, #8 # int
0a6     movl    RCX, R9 # spill
0a9     addl    RCX, #6 # int
0ac     movl    RDX, R9 # spill
0af     addl    RDX, #4 # int
0b2     addl    R9, #2  # int
0b6     movl    R10, R14    # spill
0b9     addl    R10, #22    # int
0bd     movdl   XMM3, R10   # spill
0c2     movl    RDI, R14    # spill
0c5     addl    RDI, #20    # int
0c8     movl    RAX, R14    # spill
0cb     addl    RAX, #32    # int
0ce     movl    RSI, R14    # spill
0d1     addl    RSI, #18    # int
0d4     movl    R13, R14    # spill
0d7     addl    R13, #24    # int
0db     movl    R10, R14    # spill
0de     addl    R10, #26    # int
0e2     movl    [rsp + #40], R10    # spill
0e7     movl    RBP, R14    # spill
0ea     addl    RBP, #28    # int
0ed     imull   RBP, R11    # int
0f1     addl    R14, #30    # int
0f5     imull   R14, [RSP + #36 (32-bit)]   # int
0fb     movl    R10, R8 # spill
0fe     addl    R10, #11    # int
102     movdl   R11, XMM3   # spill
107     imull   R11, R10    # int
10b     movl    [rsp + #44], R11    # spill
110     movl    R10, R8 # spill
113     addl    R10, #10    # int
117     imull   RDI, R10    # int
11b     movl    R11, R8 # spill
11e     addl    R11, #8 # int
122     movdl   R10, XMM2   # spill
127     imull   R10, R11    # int
12b     movl    [rsp + #48], R10    # spill
130     movl    R10, R8 # spill
133     addl    R10, #7 # int
137     movdl   R11, XMM1   # spill
13c     imull   R11, R10    # int
140     movl    [rsp + #52], R11    # spill
145     movl    R11, R8 # spill
148     addl    R11, #6 # int
14c     movdl   R10, XMM4   # spill
151     imull   R10, R11    # int
155     movl    [rsp + #56], R10    # spill
15a     movl    R10, R8 # spill
15d     addl    R10, #5 # int
161     movdl   R11, XMM6   # spill
166     imull   R11, R10    # int
16a     movl    [rsp + #60], R11    # spill
16f     movl    R11, R8 # spill
172     addl    R11, #4 # int
176     imull   RBX, R11    # int
17a     movl    R11, R8 # spill
17d     addl    R11, #3 # int
181     imull   RCX, R11    # int
185     movl    R10, R8 # spill
188     addl    R10, #2 # int
18c     imull   RDX, R10    # int
190     movl    R11, R8 # spill
193     incl    R11 # int
196     imull   R9, R11 # int
19a     addl    R9, [RSP + #32 (32-bit)]    # int
19f     addl    R9, RDX # int
1a2     addl    R9, RCX # int
1a5     addl    R9, RBX # int
1a8     addl    R9, [RSP + #60 (32-bit)]    # int
1ad     addl    R9, [RSP + #56 (32-bit)]    # int
1b2     addl    R9, [RSP + #52 (32-bit)]    # int
1b7     addl    R9, [RSP + #48 (32-bit)]    # int
1bc     movl    R10, R8 # spill
1bf     addl    R10, #9 # int
1c3     imull   R10, RSI    # int
1c7     addl    R10, R9 # int
1ca     addl    R10, RDI    # int
1cd     addl    R10, [RSP + #44 (32-bit)]   # int
1d2     movl    R11, R8 # spill
1d5     addl    R11, #12    # int
1d9     imull   R13, R11    # int
1dd     addl    R13, R10    # int
1e0     movl    R10, R8 # spill
1e3     addl    R10, #13    # int
1e7     imull   R10, [RSP + #40 (32-bit)]   # int
1ed     addl    R10, R13    # int
1f0     addl    RBP, R10    # int
1f3     addl    R14, RBP    # int
1f6     movl    R10, R8 # spill
1f9     addl    R10, #16    # int
1fd     cmpl    R10, #999999985
204     jl     B2   # loop end  P=1.000000 C=7419903.000000

Tutaj obserwujemy znacznie więcej „rozlewania” i więcej dostępu do stosu [RSP + ...], ze względu na więcej pośrednich wyników, które należy zachować.

Zatem odpowiedź na pytanie jest prosta: 2 * (i * i)jest szybsza niż 2 * i * iponieważ JIT generuje bardziej optymalny kod asemblera dla pierwszego przypadku.


Ale oczywiście jest oczywiste, że ani pierwsza, ani druga wersja nie jest dobra; pętla może naprawdę skorzystać z wektoryzacji, ponieważ każdy procesor x86-64 ma co najmniej obsługę SSE2.

Jest to więc kwestia optymalizatora; jak to często bywa, rozwija się zbyt agresywnie i strzela sobie w stopę, tracąc jednocześnie różne inne możliwości.

W rzeczywistości nowoczesne procesory x86-64 dzielą instrukcje na mikrooperacje (µops), a dzięki takim funkcjom, jak zmiana nazw rejestrów, pamięci podręczne µop i bufory pętli, optymalizacja pętli wymaga o wiele bardziej finezji niż zwykłe rozwijanie dla uzyskania optymalnej wydajności. Według przewodnika optymalizacji Agner Fog :

Wzrost wydajności dzięki pamięci podręcznej µop może być dość znaczny, jeśli średnia długość instrukcji jest większa niż 4 bajty. Można rozważyć następujące metody optymalizacji wykorzystania pamięci podręcznej µop:

  • Upewnij się, że pętle krytyczne są wystarczająco małe, aby zmieściły się w pamięci podręcznej µop.
  • Dopasuj najbardziej krytyczne wpisy w pętli i wpisy funkcji o 32.
  • Unikaj niepotrzebnego rozwijania pętli.
  • Unikaj instrukcji, które mają dodatkowy czas ładowania
    . . .

Jeśli chodzi o czasy ładowania - nawet najszybsze trafienie L1D kosztuje 4 cykle , dodatkowy rejestr i µop, więc tak, nawet kilka dostępów do pamięci pogorszy wydajność w ciasnych pętlach.

Wróćmy jednak do możliwości wektoryzacji - aby przekonać się, jak szybko może być, możemy skompilować podobną aplikację C za pomocą GCC , która całkowicie ją wektoryzuje (pokazano AVX2, SSE2 jest podobny) 2 :

  vmovdqa ymm0, YMMWORD PTR .LC0[rip]
  vmovdqa ymm3, YMMWORD PTR .LC1[rip]
  xor eax, eax
  vpxor xmm2, xmm2, xmm2
.L2:
  vpmulld ymm1, ymm0, ymm0
  inc eax
  vpaddd ymm0, ymm0, ymm3
  vpslld ymm1, ymm1, 1
  vpaddd ymm2, ymm2, ymm1
  cmp eax, 125000000      ; 8 calculations per iteration
  jne .L2
  vmovdqa xmm0, xmm2
  vextracti128 xmm2, ymm2, 1
  vpaddd xmm2, xmm0, xmm2
  vpsrldq xmm0, xmm2, 8
  vpaddd xmm0, xmm2, xmm0
  vpsrldq xmm1, xmm0, 4
  vpaddd xmm0, xmm0, xmm1
  vmovd eax, xmm0
  vzeroupper

Z czasem pracy:

  • SSE: 0,24 s lub 2 razy szybciej.
  • AVX: 0,15 s lub 3 razy szybciej.
  • AVX2: 0,08 s lub 5 razy szybciej.

1 Aby uzyskać dane wyjściowe zestawu wygenerowane przez JIT, pobierz JVM do debugowania i uruchom-XX:+PrintOptoAssembly

2 Wersja C jest kompilowana z -fwrapvflagą, która pozwala GCC traktować przepełnienie liczby całkowitej ze znakiem jako uzupełnienie do dwóch.


11
Jednym z największych problemów napotkanych przez optymalizator w przykładzie C jest niezdefiniowane zachowanie wywołane przepełnieniem liczby całkowitej ze znakiem. Co w przeciwnym razie prawdopodobnie spowodowałoby po prostu załadowanie stałej, ponieważ całą pętlę można obliczyć w czasie kompilacji.
Damon

44
@Damon Dlaczego niezdefiniowane zachowanie byłoby problemem dla optymalizatora? Jeśli optymalizator zobaczy, że przepełnia się on podczas próby obliczenia wyniku, oznacza to, że może go zoptymalizować w dowolny sposób, ponieważ zachowanie jest niezdefiniowane.

13
@ Runemoro: jeśli optymalizator udowodni, że wywołanie funkcji nieuchronnie spowoduje nieokreślone zachowanie, może założyć, że funkcja nigdy nie zostanie wywołana i nie wyemituje dla niej treści. Lub wyślij tylko retinstrukcję, lub wyślij etykietę, a nie instrukcję ret, więc wykonanie po prostu się kończy. GCC faktycznie zachowuje się tak, że czasami napotyka UB. Na przykład: dlaczego ret znikają z optymalizacją? . Na pewno chcesz skompilować dobrze sformułowany kod, aby mieć pewność, że asm jest rozsądny.
Peter Cordes

8
Prawdopodobnie jest to tylko wąskie gardło przepustowości interfejsu użytkownika z powodu nieefektywnego kodu. Nie używa nawet LEA jako wizjera dla mov/ add-immediate. np. movl RBX, R9/ addl RBX, #8powinno być leal ebx, [r9 + 8], 1 uop, aby skopiować i dodać. Lub leal ebx, [r9 + r9 + 16]zrobić ebx = 2*(r9+8). Więc tak, rozwijanie się do punktu rozlewania jest głupie, podobnie jak naiwny kodegen Braindead, który nie korzysta z tożsamości liczb całkowitych i matematycznych liczb całkowitych asocjacyjnych.
Peter Cordes

7
Wektoryzacja w celu sekwencyjnej redukcji została wyłączona w C2 ( bugs.openjdk.java.net/browse/JDK-8078563 ), ale obecnie rozważa się jej ponowne włączenie ( bugs.openjdk.java.net/browse/JDK-8188313 ).
zaim.

131

Gdy mnożenie jest możliwe 2 * (i * i), JVM jest w stanie wyliczyć mnożenie przez 2z pętli, co daje ten równoważny, ale bardziej wydajny kod:

int n = 0;
for (int i = 0; i < 1000000000; i++) {
    n += i * i;
}
n *= 2;

ale gdy mnożenie jest (2 * i) * i, JVM nie optymalizuje go, ponieważ mnożenie przez stałą nie jest już tuż przed dodaniem.

Oto kilka powodów, dla których myślę, że tak jest:

  • Dodanie if (n == 0) n = 1instrukcji na początku pętli powoduje, że obie wersje są tak samo wydajne, ponieważ uwzględnienie mnożenia nie gwarantuje już, że wynik będzie taki sam
  • Zoptymalizowana wersja (biorąc pod uwagę mnożenie przez 2) jest dokładnie tak szybka jak 2 * (i * i)wersja

Oto kod testowy, którego użyłem do wyciągnięcia następujących wniosków:

public static void main(String[] args) {
    long fastVersion = 0;
    long slowVersion = 0;
    long optimizedVersion = 0;
    long modifiedFastVersion = 0;
    long modifiedSlowVersion = 0;

    for (int i = 0; i < 10; i++) {
        fastVersion += fastVersion();
        slowVersion += slowVersion();
        optimizedVersion += optimizedVersion();
        modifiedFastVersion += modifiedFastVersion();
        modifiedSlowVersion += modifiedSlowVersion();
    }

    System.out.println("Fast version: " + (double) fastVersion / 1000000000 + " s");
    System.out.println("Slow version: " + (double) slowVersion / 1000000000 + " s");
    System.out.println("Optimized version: " + (double) optimizedVersion / 1000000000 + " s");
    System.out.println("Modified fast version: " + (double) modifiedFastVersion / 1000000000 + " s");
    System.out.println("Modified slow version: " + (double) modifiedSlowVersion / 1000000000 + " s");
}

private static long fastVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += 2 * (i * i);
    }
    return System.nanoTime() - startTime;
}

private static long slowVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += 2 * i * i;
    }
    return System.nanoTime() - startTime;
}

private static long optimizedVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        n += i * i;
    }
    n *= 2;
    return System.nanoTime() - startTime;
}

private static long modifiedFastVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        if (n == 0) n = 1;
        n += 2 * (i * i);
    }
    return System.nanoTime() - startTime;
}

private static long modifiedSlowVersion() {
    long startTime = System.nanoTime();
    int n = 0;
    for (int i = 0; i < 1000000000; i++) {
        if (n == 0) n = 1;
        n += 2 * i * i;
    }
    return System.nanoTime() - startTime;
}

A oto wyniki:

Fast version: 5.7274411 s
Slow version: 7.6190804 s
Optimized version: 5.1348007 s
Modified fast version: 7.1492705 s
Modified slow version: 7.2952668 s

3
Myślę o zoptymalizowanej wersji, powinna to byćn *= 2000000000;
StefansArya

4
@StefansArya - Nie. Rozważ przypadek, w którym limit wynosi 4, a my próbujemy obliczyć 2*1*1 + 2*2*2 + 2*3*3. Oczywiste jest, że obliczanie 1*1 + 2*2 + 3*3i mnożenie przez 2 jest poprawne, podczas gdy mnożenie przez 8 nie byłoby.
Martin Bonner wspiera Monikę

5
Równanie matematyczne było takie 2(1²) + 2(2²) + 2(3²) = 2(1² + 2² + 3²). To było bardzo proste i po prostu zapomniałem, ponieważ przyrost pętli.
StefansArya

5
W przypadku wydrukowania zestawu przy użyciu narzędzia JVM do debugowania nie wydaje się to poprawne. Zobaczysz kilka sall ..., # 1, które są mnożone przez 2, w pętli. Co ciekawe, wolniejsza wersja nie wydaje się mieć mnożników w pętli.
Daniel Berlin,

2
Dlaczego JVM może oddzielić 2 od, 2 * (i * i)ale nie od (2 * i) * i? Myślę, że są one równoważne (może to być moje złe założenie). Jeśli tak, to czy JVM nie kanonizuje wyrażenia przed optymalizacją?
RedSpikeyThing,

41

Kody bajtów: https://cs.nyu.edu/courses/fall00/V22.0201-001/jvm2.html Przeglądarka kodów bajtów: https://github.com/Konloch/bytecode-viewer

W moim JDK (Windows 10 64 bit, 1.8.0_65-b17) mogę odtworzyć i wyjaśnić:

public static void main(String[] args) {
    int repeat = 10;
    long A = 0;
    long B = 0;
    for (int i = 0; i < repeat; i++) {
        A += test();
        B += testB();
    }

    System.out.println(A / repeat + " ms");
    System.out.println(B / repeat + " ms");
}


private static long test() {
    int n = 0;
    for (int i = 0; i < 1000; i++) {
        n += multi(i);
    }
    long startTime = System.currentTimeMillis();
    for (int i = 0; i < 1000000000; i++) {
        n += multi(i);
    }
    long ms = (System.currentTimeMillis() - startTime);
    System.out.println(ms + " ms A " + n);
    return ms;
}


private static long testB() {
    int n = 0;
    for (int i = 0; i < 1000; i++) {
        n += multiB(i);
    }
    long startTime = System.currentTimeMillis();
    for (int i = 0; i < 1000000000; i++) {
        n += multiB(i);
    }
    long ms = (System.currentTimeMillis() - startTime);
    System.out.println(ms + " ms B " + n);
    return ms;
}

private static int multiB(int i) {
    return 2 * (i * i);
}

private static int multi(int i) {
    return 2 * i * i;
}

Wynik:

...
405 ms A 785527736
327 ms B 785527736
404 ms A 785527736
329 ms B 785527736
404 ms A 785527736
328 ms B 785527736
404 ms A 785527736
328 ms B 785527736
410 ms
333 ms

Więc dlaczego? Kod bajtu jest następujący:

 private static multiB(int arg0) { // 2 * (i * i)
     <localVar:index=0, name=i , desc=I, sig=null, start=L1, end=L2>

     L1 {
         iconst_2
         iload0
         iload0
         imul
         imul
         ireturn
     }
     L2 {
     }
 }

 private static multi(int arg0) { // 2 * i * i
     <localVar:index=0, name=i , desc=I, sig=null, start=L1, end=L2>

     L1 {
         iconst_2
         iload0
         imul
         iload0
         imul
         ireturn
     }
     L2 {
     }
 }

Różnica polega na: W nawiasach ( 2 * (i * i)):

  • push const stack
  • push lokalny na stosie
  • push lokalny na stosie
  • pomnożyć górę stosu
  • pomnożyć górę stosu

Bez nawiasów ( 2 * i * i):

  • push const stack
  • push lokalny na stosie
  • pomnożyć górę stosu
  • push lokalny na stosie
  • pomnożyć górę stosu

Ładowanie wszystkich na stos, a następnie powrót do pracy jest szybsze niż przełączanie między nakładaniem na stos i działaniem na nim.


Ale dlaczego push-push-multiply-multiply jest szybszy niż push-multiply-push-multiply?
m0skit0,

35

Kasperd zapytał w komentarzu do zaakceptowanej odpowiedzi:

Przykłady Java i C używają całkiem różnych nazw rejestrów. Czy oba przykłady używają AMD64 ISA?

xor edx, edx
xor eax, eax
.L2:
mov ecx, edx
imul ecx, edx
add edx, 1
lea eax, [rax+rcx*2]
cmp edx, 1000000000
jne .L2

Nie mam wystarczającej reputacji, aby odpowiedzieć na to w komentarzach, ale są to te same ISA. Warto zauważyć, że wersja GCC używa 32-bitowej logiki liczb całkowitych, a wersja skompilowana w JVM używa 64-bitowej logiki liczb wewnętrznych.

R8 do R15 to tylko nowe rejestry X86_64 . EAX do EDX to dolne części rejestrów ogólnego przeznaczenia RAX do RDX. Ważną częścią odpowiedzi jest to, że wersja GCC nie jest rozwinięta. Po prostu wykonuje jedną rundę pętli na rzeczywistą pętlę kodu maszynowego. Podczas gdy wersja JVM ma 16 rund pętli w jednej pętli fizycznej (na podstawie odpowiedzi Rustyxa, nie zinterpretowałem zestawu). Jest to jeden z powodów, dla których używanych jest więcej rejestrów, ponieważ korpus pętli jest w rzeczywistości 16 razy dłuższy.


2
Szkoda, że ​​gcc nie zauważa, że ​​może zatonąć *2w pętli. Chociaż w tym przypadku nie jest to nawet wygrana, ponieważ robi to za darmo z LEA. Na procesorach Intel lea eax, [rax+rcx*2]ma takie samo opóźnienie 1c jak add eax,ecx. Jednak w procesorach AMD każdy skalowany indeks zwiększa opóźnienie LEA do 2 cykli. Tak więc łańcuch zależności przenoszony przez pętlę wydłuża się do 2 cykli, stając się wąskim gardłem na Ryzen. ( imul ecx,edxprzepustowość wynosi 1 na zegar na Ryzen i na Intel).
Peter Cordes,

31

Chociaż nie jest to bezpośrednio związane ze środowiskiem pytania, tylko dla ciekawości, zrobiłem ten sam test na .NET Core 2.1, x64, tryb wydania.

Oto interesujący wynik, potwierdzający podobne zjawiska (odwrotnie) zachodzące nad ciemną stroną siły. Kod:

static void Main(string[] args)
{
    Stopwatch watch = new Stopwatch();

    Console.WriteLine("2 * (i * i)");

    for (int a = 0; a < 10; a++)
    {
        int n = 0;

        watch.Restart();

        for (int i = 0; i < 1000000000; i++)
        {
            n += 2 * (i * i);
        }

        watch.Stop();

        Console.WriteLine($"result:{n}, {watch.ElapsedMilliseconds} ms");
    }

    Console.WriteLine();
    Console.WriteLine("2 * i * i");

    for (int a = 0; a < 10; a++)
    {
        int n = 0;

        watch.Restart();

        for (int i = 0; i < 1000000000; i++)
        {
            n += 2 * i * i;
        }

        watch.Stop();

        Console.WriteLine($"result:{n}, {watch.ElapsedMilliseconds}ms");
    }
}

Wynik:

2 * (i * i)

  • wynik: 119860736, 438 ms
  • wynik: 119860736, 433 ms
  • wynik: 119860736, 437 ms
  • wynik: 119860736, 435 ms
  • wynik: 119860736, 436 ms
  • wynik: 119860736, 435 ms
  • wynik: 119860736, 435 ms
  • wynik: 119860736, 439 ms
  • wynik: 119860736, 436 ms
  • wynik: 119860736, 437 ms

2 * i * i

  • wynik: 119860736, 417 ms
  • wynik: 119860736, 417 ms
  • wynik: 119860736, 417 ms
  • wynik: 119860736, 418 ms
  • wynik: 119860736, 418 ms
  • wynik: 119860736, 417 ms
  • wynik: 119860736, 418 ms
  • wynik: 119860736, 416 ms
  • wynik: 119860736, 417 ms
  • wynik: 119860736, 418 ms

1
Chociaż nie jest to odpowiedź na pytanie, wnosi wartość dodaną. Biorąc to pod uwagę, jeśli coś jest istotne dla twojego postu, prosimy o umieszczenie go w wierszu zamiast linkowania do zasobu poza witryną. Linki znikają.
Jared Smith

1
@JaredSmith Dzięki za opinie. Biorąc pod uwagę, że wspomniany link jest linkiem „wynikowym”, obraz ten nie jest źródłem zewnętrznym. Przesłałem go do stackoverflow za pośrednictwem własnego panelu.
Ünsal Ersöz

1
To jest link do imgur, więc tak, to nie ma znaczenia, jak dodałeś link. Nie widzę, co jest takiego trudnego w kopiowaniu i wklejaniu niektórych danych wyjściowych konsoli.
Jared Smith

5
Z wyjątkiem tego, że jest na odwrót
leppie

2
@SamB wciąż znajduje się w domenie imgur.com, co oznacza, że ​​przetrwa tylko tak długo, jak imgur.
p91paul

21

Mam podobne wyniki:

2 * (i * i): 0.458765943 s, n=119860736
2 * i * i: 0.580255126 s, n=119860736

Otrzymałem SAME wyniki, jeśli obie pętle były w tym samym programie lub każda z nich była w osobnym pliku .java / .class, wykonanym przy oddzielnym uruchomieniu.

Wreszcie, oto javap -c -v <.java>dekompilacja każdego z nich:

     3: ldc           #3                  // String 2 * (i * i):
     5: invokevirtual #4                  // Method java/io/PrintStream.print:(Ljava/lang/String;)V
     8: invokestatic  #5                  // Method java/lang/System.nanoTime:()J
     8: invokestatic  #5                  // Method java/lang/System.nanoTime:()J
    11: lstore_1
    12: iconst_0
    13: istore_3
    14: iconst_0
    15: istore        4
    17: iload         4
    19: ldc           #6                  // int 1000000000
    21: if_icmpge     40
    24: iload_3
    25: iconst_2
    26: iload         4
    28: iload         4
    30: imul
    31: imul
    32: iadd
    33: istore_3
    34: iinc          4, 1
    37: goto          17

vs.

     3: ldc           #3                  // String 2 * i * i:
     5: invokevirtual #4                  // Method java/io/PrintStream.print:(Ljava/lang/String;)V
     8: invokestatic  #5                  // Method java/lang/System.nanoTime:()J
    11: lstore_1
    12: iconst_0
    13: istore_3
    14: iconst_0
    15: istore        4
    17: iload         4
    19: ldc           #6                  // int 1000000000
    21: if_icmpge     40
    24: iload_3
    25: iconst_2
    26: iload         4
    28: imul
    29: iload         4
    31: imul
    32: iadd
    33: istore_3
    34: iinc          4, 1
    37: goto          17

FYI -

java -version
java version "1.8.0_121"
Java(TM) SE Runtime Environment (build 1.8.0_121-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.121-b13, mixed mode)

1
Lepsza odpowiedź i być może możesz zagłosować na cofnięcie usunięcia - stackoverflow.com/a/53452836/1746118 ... Uwaga: - I tak nie jestem zwycięzcą.
Naman

@nullpointer - Zgadzam się. Na pewno zagłosowałbym za usunięciem, jeśli bym mógł. Chciałbym również „podwoić głos” stefana za podanie ilościowej definicji „znaczącego”
paulsm4,

Ten został skasowany samodzielnie, ponieważ zmierzył niewłaściwą rzecz - patrz komentarz tego autora do powyższego pytania
Krease

2
Pobierz środowisko debugowania i uruchom -XX:+PrintOptoAssembly. Lub po prostu użyj vtune lub podobnego.
rustyx

1
@ rustyx - Jeśli problemem jest implementacja JIT ... to „uzyskanie wersji debugowania” ZUPEŁNIE RÓŻNEGO JRE niekoniecznie pomoże. Niemniej jednak: brzmi to tak, jak to, co znalazłeś powyżej przy demontażu JIT w JRE, wyjaśnia również zachowanie JRE OP i mojego. I wyjaśnia również, dlaczego inne środowiska JRE zachowują się „inaczej”. +1: dziękuję za doskonałą pracę detektywistyczną!
paulsm4

18

Ciekawa obserwacja za pomocą Java 11 i wyłączanie rozwijania pętli za pomocą następującej opcji maszyny wirtualnej:

-XX:LoopUnrollLimit=0

Pętla z 2 * (i * i)wyrażeniem powoduje zwarty kod natywny 1 :

L0001: add    eax,r11d
       inc    r8d
       mov    r11d,r8d
       imul   r11d,r8d
       shl    r11d,1h
       cmp    r8d,r10d
       jl     L0001

w porównaniu z 2 * i * iwersją:

L0001: add    eax,r11d
       mov    r11d,r8d
       shl    r11d,1h
       add    r11d,2h
       inc    r8d
       imul   r11d,r8d
       cmp    r8d,r10d
       jl     L0001

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)

Wyniki testu porównawczego:

Benchmark          (size)  Mode  Cnt    Score     Error  Units
LoopTest.fast  1000000000  avgt    5  694,868 ±  36,470  ms/op
LoopTest.slow  1000000000  avgt    5  769,840 ± 135,006  ms/op

Kod źródłowy testu porównawczego:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS)
@State(Scope.Thread)
@Fork(1)
public class LoopTest {

    @Param("1000000000") private int size;

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
            .include(LoopTest.class.getSimpleName())
            .jvmArgs("-XX:LoopUnrollLimit=0")
            .build();
        new Runner(opt).run();
    }

    @Benchmark
    public int slow() {
        int n = 0;
        for (int i = 0; i < size; i++)
            n += 2 * i * i;
        return n;
    }

    @Benchmark
    public int fast() {
        int n = 0;
        for (int i = 0; i < size; i++)
            n += 2 * (i * i);
        return n;
    }
}

1 - Zastosowane opcje VM: -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:LoopUnrollLimit=0


2
Wow, to jakiś asm Braindead. Zamiast inkrementować i przed skopiowaniem go do obliczenia 2*i, robi to po, więc potrzebuje dodatkowej add r11d,2instrukcji. (Plus brakuje add same,samewizjera zamiast o shl1 (dodaj działa na większej liczbie portów). Brakuje również wizjera LEA dla x*2 + 2( lea r11d, [r8*2 + 2]), jeśli naprawdę chce robić rzeczy w tej kolejności z jakiegoś szalonego powodu planowania instrukcji. Widzieliśmy już z wersja rozwijana, w której brakowało LEA, kosztowała ją wiele ulepszeń, tak samo jak tutaj obie pętle
Peter Cordes

2
lea eax, [rax + r11 * 2]zastąpiłby 2 instrukcje (w obu pętlach), gdyby kompilator JIT miał czas na poszukiwanie tej optymalizacji w długo działających pętlach. Dowolny kompilator z wyprzedzeniem by go znalazł. (Chyba że strojenie tylko dla AMD, gdzie LEA ze skalowanym indeksem ma opóźnienie 2 cykli, więc może nie jest tego warte.)
Peter Cordes

15

Wypróbowałem JMH przy użyciu domyślnego archetypu: dodałem również zoptymalizowaną wersję opartą na wyjaśnieniu Runemoro .

@State(Scope.Benchmark)
@Warmup(iterations = 2)
@Fork(1)
@Measurement(iterations = 10)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
//@BenchmarkMode({ Mode.All })
@BenchmarkMode(Mode.AverageTime)
public class MyBenchmark {
  @Param({ "100", "1000", "1000000000" })
  private int size;

  @Benchmark
  public int two_square_i() {
    int n = 0;
    for (int i = 0; i < size; i++) {
      n += 2 * (i * i);
    }
    return n;
  }

  @Benchmark
  public int square_i_two() {
    int n = 0;
    for (int i = 0; i < size; i++) {
      n += i * i;
    }
    return 2*n;
  }

  @Benchmark
  public int two_i_() {
    int n = 0;
    for (int i = 0; i < size; i++) {
      n += 2 * i * i;
    }
    return n;
  }
}

Rezultaty są tutaj:

Benchmark                           (size)  Mode  Samples          Score   Score error  Units
o.s.MyBenchmark.square_i_two           100  avgt       10         58,062         1,410  ns/op
o.s.MyBenchmark.square_i_two          1000  avgt       10        547,393        12,851  ns/op
o.s.MyBenchmark.square_i_two    1000000000  avgt       10  540343681,267  16795210,324  ns/op
o.s.MyBenchmark.two_i_                 100  avgt       10         87,491         2,004  ns/op
o.s.MyBenchmark.two_i_                1000  avgt       10       1015,388        30,313  ns/op
o.s.MyBenchmark.two_i_          1000000000  avgt       10  967100076,600  24929570,556  ns/op
o.s.MyBenchmark.two_square_i           100  avgt       10         70,715         2,107  ns/op
o.s.MyBenchmark.two_square_i          1000  avgt       10        686,977        24,613  ns/op
o.s.MyBenchmark.two_square_i    1000000000  avgt       10  652736811,450  27015580,488  ns/op

Na moim komputerze ( Core i7 860 - nie robi nic poza czytaniem na moim smartfonie):

  • n += i*i następnie n*2 jest pierwszy
  • 2 * (i * i) jest drugi.

JVM najwyraźniej nie optymalizuje w taki sam sposób jak człowiek (na podstawie odpowiedzi Runemoro).

Teraz czytając kod bajtowy: javap -c -v ./target/classes/org/sample/MyBenchmark.class

Nie jestem ekspertem w dziedzinie kodu bajtowego, ale my iload_2przedtem imul: pewnie na tym polega różnica: mogę założyć, że JVM optymalizuje odczyt idwa razy ( ijest już tutaj i nie ma potrzeby ładowania go ponownie), podczas 2*i*igdy może t.


4
Kod bajtowy AFAICT jest dość nieistotny dla wydajności i nie próbowałbym na podstawie tego oszacować, co jest szybsze. Jest to po prostu kod źródłowy kompilatora JIT ... z pewnością zachowujące znaczenie zmiany kolejności wierszy kodu źródłowego mogą zmienić wynikowy kod i jego wydajność, ale to wszystko jest dość nieprzewidywalne.
maaartinus

13

Więcej aneksu. Powtórzyłem eksperyment przy użyciu najnowszej JVM Java 8 firmy IBM:

java version "1.8.0_191"
Java(TM) 2 Runtime Environment, Standard Edition (IBM build 1.8.0_191-b12 26_Oct_2018_18_45 Mac OS X x64(SR5 FP25))
Java HotSpot(TM) 64-Bit Server VM (build 25.191-b12, mixed mode)

I to pokazuje bardzo podobne wyniki:

0.374653912 s
n = 119860736
0.447778698 s
n = 119860736

(drugie wyniki przy użyciu 2 * i * i).

Co ciekawe, podczas uruchamiania na tym samym komputerze, ale przy użyciu Oracle Java:

Java version "1.8.0_181"
Java(TM) SE Runtime Environment (build 1.8.0_181-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.181-b13, mixed mode)

wyniki są średnio nieco wolniejsze:

0.414331815 s
n = 119860736
0.491430656 s
n = 119860736

Krótko mówiąc: nawet niewielki numer wersji HotSpot ma tutaj znaczenie, ponieważ subtelne różnice w implementacji JIT mogą mieć znaczące efekty.


5

Dwie metody dodawania generują nieco inny kod bajtowy:

  17: iconst_2
  18: iload         4
  20: iload         4
  22: imul
  23: imul
  24: iadd

Dla 2 * (i * i)vs:

  17: iconst_2
  18: iload         4
  20: imul
  21: iload         4
  23: imul
  24: iadd

Dla 2 * i * i .

Podczas korzystania z testu porównawczego JMH takiego:

@Warmup(iterations = 5, batchSize = 1)
@Measurement(iterations = 5, batchSize = 1)
@Fork(1)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
public class MyBenchmark {

    @Benchmark
    public int noBrackets() {
        int n = 0;
        for (int i = 0; i < 1000000000; i++) {
            n += 2 * i * i;
        }
        return n;
    }

    @Benchmark
    public int brackets() {
        int n = 0;
        for (int i = 0; i < 1000000000; i++) {
            n += 2 * (i * i);
        }
        return n;
    }

}

Różnica jest wyraźna:

# JMH version: 1.21
# VM version: JDK 11, Java HotSpot(TM) 64-Bit Server VM, 11+28
# VM options: <none>

Benchmark                      (n)  Mode  Cnt    Score    Error  Units
MyBenchmark.brackets    1000000000  avgt    5  380.889 ± 58.011  ms/op
MyBenchmark.noBrackets  1000000000  avgt    5  512.464 ± 11.098  ms/op

To, co obserwujesz, jest poprawne, a nie tylko anomalią Twojego stylu testu porównawczego (tj. Bez rozgrzewki, zobacz Jak napisać poprawny mikroprocesor w Javie? )

Ponowne uruchamianie z Graal:

# JMH version: 1.21
# VM version: JDK 11, Java HotSpot(TM) 64-Bit Server VM, 11+28
# VM options: -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseJVMCICompiler

Benchmark                      (n)  Mode  Cnt    Score    Error  Units
MyBenchmark.brackets    1000000000  avgt    5  335.100 ± 23.085  ms/op
MyBenchmark.noBrackets  1000000000  avgt    5  331.163 ± 50.670  ms/op

Widzisz, że wyniki są znacznie bliższe, co ma sens, ponieważ Graal jest ogólnie lepiej działającym, bardziej nowoczesnym kompilatorem.

Tak więc tak naprawdę zależy to od tego, jak dobrze kompilator JIT jest w stanie zoptymalizować określony fragment kodu i niekoniecznie ma logiczny powód.

Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.