Java'da neden 2 * (i * i) 2 * i * i'den daha hızlı?


855

Aşağıdaki Java programının çalışması ortalama 0.50 saniye ile 0.55 saniye arasındadır:

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

Ben değiştirirseniz 2 * (i * i)ile 2 * i * i, yayınlanmaya 0,60 ila 0,65 saniye sürer. Nasıl olur?

Programın her sürümünü ikisi arasında dönüşümlü olarak 15 kez çalıştırdım. Sonuçlar burada:

 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

En hızlı koşusu, 2 * i * ien yavaş koşusundan daha uzun sürdü 2 * (i * i). Aynı verimliliğe sahip olsaydı, bunun olma olasılığı daha az olurdu 1/2^15 * 100% = 0.00305%.


5
Benzer sonuçlar alıyorum (biraz farklı sayılar, ancak kesinlikle fark edilir ve tutarlı bir boşluk, kesinlikle örnekleme hatasından daha fazla)
20'de Kreasyonu artırın


3
@Krease Hatamı yakalaman iyi oldu. Yeni karşılaştırmaya göre koştum 2 * i * idaha yavaş. Graal ile de koşmayı deneyeceğim.
Jorn Vernee

5
@nullpointer Birinin neden diğerinden daha hızlı olduğunu öğrenmek için, bu yöntemler için sökme veya İdeal grafikleri elde etmeliyiz. Montajcı denemek ve anlamaya çalışmak çok can sıkıcı, bu yüzden güzel grafikler çıktısı bir OpenJDK hata ayıklama yapı almaya çalışıyorum.
Jorn Vernee

4
Sorunun işlemlerin sırasına göre daha iyi anlaşılması için sorunuzu " Neden daha i * i * 2hızlı 2 * i * i? " Olarak yeniden adlandırabilirsiniz .
Cur

Yanıtlar:


1202

Bayt kodunun sıralamasında küçük bir fark vardır.

2 * (i * i):

     iconst_2
     iload0
     iload0
     imul
     imul
     iadd

vs 2 * i * i:

     iconst_2
     iload0
     imul
     iload0
     imul
     iadd

İlk bakışta bu bir fark yaratmamalıdır; bir şey daha az bir yuva kullandığından ikinci sürümü daha uygun.

Bu yüzden alt seviyeye (JIT) daha derinlemesine bakmamız gerekiyor 1 .

JIT'in küçük döngüleri çok agresif bir şekilde açma eğiliminde olduğunu unutmayın. Gerçekten de, 2 * (i * i)davayı 16 kez açığa çıkarıyoruz :

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

Yığına "dökülen" bir kayıt olduğunu görüyoruz.

Ve 2 * i * isürüm için:

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

Burada [RSP + ...], korunması gereken daha fazla ara sonuç nedeniyle, çok daha fazla "dökülme" ve yığına daha fazla erişim gözlemliyoruz .

Bu nedenle sorunun cevabı basittir: JIT, ilk durum için daha uygun montaj kodu oluşturduğundan daha 2 * (i * i)hızlıdır 2 * i * i.


Ancak elbette ki ne ilk ne de ikinci versiyonun iyi olmadığı açıktır; herhangi bir x86-64 CPU en az SSE2 desteğine sahip olduğundan, döngü gerçekten vektörleştirmeden yararlanabilir.

Dolayısıyla bu, optimize edicinin bir sorunu; çoğu zaman olduğu gibi, çok agresif bir şekilde açılır ve diğer çeşitli fırsatları kaçırırken kendini ayağından vurur.

Aslında, modern x86-64 CPU'lar talimatları mikro-op'lara (µops) daha da böler ve kayıt yeniden adlandırma, µop önbellekleri ve döngü tamponları gibi özelliklerle döngü optimizasyonu, optimum performans için basit bir açmadan çok daha fazla incelik alır. Agner Fog'un optimizasyon kılavuzuna göre :

Ortalama komut uzunluğu 4 bayttan fazlaysa, µop önbelleğine bağlı performans artışı oldukça önemli olabilir. Μop önbellek kullanımını optimize etmek için aşağıdaki yöntemler düşünülebilir:

  • Kritik döngülerin µop önbelleğine sığacak kadar küçük olduğundan emin olun.
  • En kritik döngü girişlerini ve işlev girişlerini 32 ile hizalayın.
  • Gereksiz döngü kilidini açmaktan kaçının.
  • Ekstra yükleme süresi olan talimatlardan kaçının
    . . .

Bu yükleme süreleri ile ilgili olarak - en hızlı L1D isabetinin bile 4 döngü , ekstra bir kayıt ve µop maliyeti vardır, bu nedenle evet, belleğe birkaç erişim bile sıkı döngülerdeki performansa zarar verir.

Ancak vektörleştirme fırsatına geri dönün - ne kadar hızlı olabileceğini görmek için, GCC ile benzer bir C uygulamasını derleyebiliriz , bu da onu açıkça vektörleştirir (AVX2 gösterilmiştir, SSE2 benzerdir) 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

Çalışma süreleri ile:

  • SSE: 0,24 s veya 2 kat daha hızlı.
  • AVX: 0,15 s veya 3 kat daha hızlı.
  • AVX2: 0,08 s veya 5 kat daha hızlı.

1 JIT tarafından üretilen derleme çıktısını almak için bir hata ayıklama JVM'si edinin ve-XX:+PrintOptoAssembly

2 C versiyonu, -fwrapvGCC'nin işaretli tamsayı taşmasını ikinin tamamlayıcı sargısı olarak görmesini sağlayan bayrakla derlenmiştir .


11
Optimize edicinin C örneğinde karşılaştığı en büyük sorun, işaretli tamsayı taşması tarafından çağrılan tanımsız davranıştır. Aksi takdirde, bütün döngü derleme sırasında hesaplanabileceğinden, muhtemelen bir sabitin yüklenmesine neden olur.
Damon

44
@Damon Tanımsız davranış neden iyileştirici için bir sorun olabilir? Optimize edici, sonucu hesaplamaya çalışırken taştığını görürse, bu davranış istediği gibi optimize edebileceği anlamına gelir, çünkü davranış tanımsızdır.

13
@Runemoro: optimizer işlevi çağırmanın kaçınılmaz olarak tanımlanamayan bir davranışla sonuçlanacağını kanıtlarsa, işlevin hiçbir zaman çağrılmayacağını varsayabilir ve bunun için hiçbir gövde yaymaz. Ya da sadece bir rettalimat ya da bir etiket yayarsınız ve ret talimatı yoktur, böylece yürütme gerçekleşir. GCC aslında bazen UB ile karşılaştığında olduğu gibi davranır. Örneğin: ret optimizasyon ile neden yok oluyor? . Asm'ın aklı başında olduğundan emin olmak için kesinlikle iyi biçimlendirilmiş kodu derlemek istiyorsunuz.
Peter Cordes

8
Muhtemelen verimsiz kod-gen nedeniyle sadece bir ön uç uop verim darboğazıdır. mov/ İçin bir gözetleme deliği olarak LEA bile kullanmıyor add-immediate. örneğin movl RBX, R9/ addl RBX, #8olmalı leal ebx, [r9 + 8], 1 uop kopyalayıp ekleyin. Ya leal ebx, [r9 + r9 + 16]da yapmak ebx = 2*(r9+8). Yani evet, dökülme noktasına açılmak aptalca ve tamsayı kimliklerden ve çağrışımsal tamsayı matematiğinden faydalanmayan saf braindead codegen de öyle.
Peter Cordes

7
Sıralı indirgeme için vektörizasyon C2'de ( bugs.openjdk.java.net/browse/JDK-8078563 ) devre dışı bırakıldı , ancak şimdi yeniden etkinleştirme için düşünülmektedir ( bugs.openjdk.java.net/browse/JDK-8188313 ).
pron

131

Çarpma olduğunda 2 * (i * i), JVM çarpımı 2döngüden çarpanlarına ayırabilir ve bu da eşdeğer ancak daha verimli bir kodla sonuçlanabilir:

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

ancak çarpma olduğunda (2 * i) * iJVM bunu optimize etmez, çünkü sabit ile çarpma artık toplama işleminden hemen önce değildir.

İşte böyle olduğunu düşünüyorum birkaç neden:

  • if (n == 0) n = 1Döngünün başına bir deyim eklemek her iki sürümün de verimli olmasıyla sonuçlanır, çünkü çarpmayı çarpanlarına ayırmak artık sonucun aynı olacağını garanti etmez
  • Optimize edilmiş sürüm (çarpma oranını 2 ile çarpanlarına ayırarak) 2 * (i * i)sürüm kadar hızlıdır

İşte bu sonuçları çıkarmak için kullandığım test kodu:

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

İşte sonuçlar:

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
Ben optimize optimize n *= 2000000000;
sürümü

4
@StefansArya - Hayır. Sınırın 4 olduğu durumu düşünün ve hesaplamaya çalışıyoruz 2*1*1 + 2*2*2 + 2*3*3. 1*1 + 2*2 + 3*32 ile hesaplamanın ve çarpmanın doğru olduğu açıkken, 8 ile çarpmanın doğru olmadığı açıktır .
Martin Bonner Monica'yı

5
Matematik denklemi böyleydi 2(1²) + 2(2²) + 2(3²) = 2(1² + 2² + 3²). Bu çok basitti ve sadece unuttum çünkü döngü artışı.
StefansArya

5
Bir hata ayıklama jvm kullanarak derleme yazdırırsanız, bu doğru gibi görünmüyor. Döngüde 2 ile çarpılan bir grup sall ..., # 1 göreceksiniz. İlginç bir şekilde, daha yavaş sürümün döngüde katları var gibi görünmüyor.
Daniel Berlin

2
Neden olabilir dan 2 üzerinden JVM faktörü 2 * (i * i)ancak gelen (2 * i) * i? Eşdeğer olduğunu düşünürdüm (bu benim kötü varsayımım olabilir). Öyleyse, JVM optimizasyondan önce ifadeyi standartlaştırmaz mı?
RedSpikeyThing

41

Bayt kodları: https://cs.nyu.edu/courses/fall00/V22.0201-001/jvm2.html Bayt kodları Görüntüleyici: https://github.com/Konloch/bytecode-viewer

JDK'mda (Windows 10 64 bit, 1.8.0_65-b17) çoğaltabilir ve açıklayabilirim:

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

Çıktı:

...
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

Peki neden? Bayt kodu şudur:

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

Fark: Parantez ( 2 * (i * i)) ile:

  • sabit yığın itmek
  • yığında yerel olarak it
  • yığında yerel olarak it
  • yığının üstünü çarp
  • yığının üstünü çarp

Parantez olmadan ( 2 * i * i):

  • sabit yığın itmek
  • yığında yerel olarak it
  • yığının üstünü çarp
  • yığında yerel olarak it
  • yığının üstünü çarp

Yığına hepsini yüklemek ve sonra geri çalışmak, yığını takmak ve üzerinde çalışmak arasında geçiş yapmaktan daha hızlıdır.


Peki itme-çarpma-çarpma-çarpma neden itme-çarpma-basma-çarpmadan daha hızlıdır?
m0skit0

35

Kasperd kabul edilen cevabın bir yorumunda sordu:

Java ve C örnekleri oldukça farklı kayıt adları kullanır. Her ikisi de AMD64 ISA kullanan bir örnek midir?

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

Yorumlarda buna cevap vermek için yeterli itibarım yok, ancak bunlar aynı ISA. GCC sürümünün 32 bit tam sayı mantığı ve JVM derlenmiş sürümünün dahili olarak 64 bit tam sayı mantığı kullandığını belirtmek gerekir.

R8 - R15 sadece yeni X86_64 kayıtlarıdır . EAX - EDX, RAX - RDX genel amaçlı kayıtlarının alt parçalarıdır. Cevabın önemli kısmı, GCC versiyonunun açılmamış olmasıdır. Sadece gerçek makine kodu döngüsü başına bir döngü turu yürütür. JVM sürümü, bir fiziksel döngüde 16 tur döngü olsa da (rustyx cevabına dayanarak, montajı yeniden yorumlamadım). Döngü gövdesi aslında 16 kat daha uzun olduğu için daha fazla kayıt kullanılmasının nedenlerinden biri budur.


2
Çok kötü gcc *2döngü dışına batabilir fark etmez . Bu durumda, bunu yapmak bile bir kazanç değil, çünkü LEA ile ücretsiz yapıyor. Intel CPU'larda, lea eax, [rax+rcx*2]1c gecikme süresine sahiptir add eax,ecx. Ancak, AMD CPU'larda herhangi bir ölçeklendirilmiş dizin LEA gecikmesini 2 döngüye çıkarır. Böylece, döngüde taşınan bağımlılık zinciri 2 döngüye kadar uzar ve Ryzen'de darboğaz haline gelir. ( imul ecx,edxRyzen ve Intel'de verim saat başına 1'dir).
Peter Cordes

31

Sorunun çevresi ile doğrudan ilgili olmasa da, sadece merak için, .NET Core 2.1, x64, sürüm modunda aynı testi yaptım.

İşte ilginç sonuç, kuvvetin karanlık tarafında benzer fonomenaları (başka bir şekilde) teyit ediyor. 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");
    }
}

Sonuç:

2 * (i * i)

  • sonuç: 119860736, 438 ms
  • sonuç: 119860736, 433 ms
  • sonuç: 119860736, 437 ms
  • sonuç: 119860736, 435 ms
  • sonuç: 119860736, 436 ms
  • sonuç: 119860736, 435 ms
  • sonuç: 119860736, 435 ms
  • sonuç: 119860736, 439 ms
  • sonuç: 119860736, 436 ms
  • sonuç: 119860736, 437 ms

2 * i * i

  • sonuç: 119860736, 417 ms
  • sonuç: 119860736, 417 ms
  • sonuç: 119860736, 417 ms
  • sonuç: 119860736, 418 ms
  • sonuç: 119860736, 418 ms
  • sonuç: 119860736, 417 ms
  • sonuç: 119860736, 418 ms
  • sonuç: 119860736, 416 ms
  • sonuç: 119860736, 417 ms
  • sonuç: 119860736, 418 ms

1
Bu soruya bir cevap olmasa da, değer katıyor. Bununla birlikte, postanız için bir şey hayati önem taşıyorsa, lütfen site dışı bir kaynağa bağlantı vermek yerine gönderiye satır içi ekleyin. Bağlantılar kesiliyor.
Jared Smith

1
@JaredSmith Geri bildiriminiz için teşekkür ederiz. Bahsettiğiniz bağlantı "sonuç" bağlantısı olarak değerlendirildiğinde, bu resim site dışı bir kaynak değildir. Kendi panelinden stackoverflow'a yükledim.
Ünsal Ersöz

1
Bu imgur için bir bağlantı, yani evet, bağlantıyı nasıl eklediğiniz önemli değil. Bazı konsol çıktılarını kopyalayıp yapıştırma konusunda neyin zor olduğunu göremiyorum.
Jared Smith

5
Dışında bu başka bir yol
leppie

2
@SamB hala imgur.com etki alanında, yani imgur kadar uzun süre hayatta kalacak.
p91paul

21

Benzer sonuçlar aldım:

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

Her iki döngü de aynı programdaysa veya her biri ayrı bir çalıştırmada çalıştırılan ayrı bir .java dosyası / .class'daysa AYNI sonuçları aldım .

Son olarak, javap -c -v <.java>her birinin bir kodası:

     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

Bilginize -

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
Daha iyi bir yanıt ve belki de silmeyi geri almak için oy verebilirsiniz - stackoverflow.com/a/53452836/1746118 ... Yan not - Ben zaten downvoter değilim.
Naman

@nullpointer - Kabul ediyorum. Silme işlemini geri almak için kesinlikle oy verirdim. Ayrıca "önemli" kantitatif bir tanım vermek için "çift upvote" stefan istiyorum
paulsm4

Yanlış olanı ölçtüğü için bu kişi kendi kendine silindi - yazarın yukarıdaki soru hakkındaki
yorumuna bakın

2
Bir hata ayıklama jre alın ve ile çalıştırın -XX:+PrintOptoAssembly. Veya sadece vtune veya benzerlerini kullanın.
rustyx

1
@ rustyx - Sorun JIT uygulaması ise ... o zaman TAMAMEN FARKLI bir JRE "hata ayıklama sürümü alma" mutlaka yardımcı olmayacaktır. Yine de: JRE'nizdeki JIT sökme ile yukarıda bulduğunuz gibi görünüyor , ayrıca OP'nin JRE ve benimkiyle ilgili davranışı da açıklıyor. Ayrıca diğer JRE'lerin neden "farklı" davrandıklarını da açıklar. +1: mükemmel dedektiflik işi için teşekkürler!
paulsm4

18

Java 11'i kullanarak ilginç gözlem ve aşağıdaki VM seçeneğiyle döngü açmayı kapatma:

-XX:LoopUnrollLimit=0

İfadeli döngü 2 * (i * i)daha kompakt yerel kod 1 ile sonuçlanır :

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

2 * i * isürümle karşılaştırıldığında :

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

Java sürümü:

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)

Deney sonuçları:

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

Karşılaştırma kaynak kodu:

@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 - Kullanılan VM seçenekleri: -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:LoopUnrollLimit=0


2
Vay be, bu biraz cesur bir grup. Hesaplamak için kopyalamadan i önce artırmak yerine 2*i, daha sonra yapar, bu yüzden ekstra bir add r11d,2talimat gerektirir. (Artı 1 add same,sameyerine gözetleme deliğini özlüyor shl(daha fazla bağlantı noktasına koşu ekliyor). Ayrıca , çılgın bir talimat çizelgeleme nedeni için gerçekten bu sırayla bir şeyler yapmak istiyorsa x*2 + 2( lea r11d, [r8*2 + 2]) için bir LEA gözetleme deliğini özlüyor . LEA'da eksik olan açılmamış versiyon, her iki döngüde de olduğu gibi çok fazla maliyete sahipti
Peter Cordes

2
lea eax, [rax + r11 * 2]JIT derleyicisinin bu optimizasyonu uzun süren döngülerde aramak için zamanı olsaydı 2 talimatın (her iki döngüde) yerini alacaktı. Her zaman iyi bir derleyici onu bulur. (Sadece ölçeklendirilmiş dizin LEA'nın 2 döngü gecikmesine sahip olduğu, belki de buna değmeyeceği AMD için ayarlanmadığı sürece.)
Peter Cordes

15

Varsayılan arketipi kullanarak bir JMH denedim: Ayrıca Runemoro'nun açıklamasına göre optimize edilmiş bir sürüm ekledim .

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

Sonuç burada:

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

Bilgisayarımda ( Core i7 860 - akıllı telefonumda okumaktan başka bir şey yapmıyor):

  • n += i*io n*2zaman ilk
  • 2 * (i * i) ikinci.

JVM, açıkça bir insanın yaptığı ile aynı şekilde optimizasyon yapmıyor (Runemoro'nun cevabına dayanarak).

Şimdi bayt kodunu okuma: javap -c -v ./target/classes/org/sample/MyBenchmark.class

Ben baytkoduna konusunda uzman değilim, ama biz iload_2önce Bİz imulOkumayı JVM optimize varsayalım edebilirsiniz: Aradaki farkı nereden o muhtemelen iiki kez ( izaten burada ve tekrar yüklemek için gerek yoktur) içinde iken 2*i*i'elinden t.


4
AFAICT bayt kodu performans için oldukça alakasız ve buna göre daha hızlı olanı tahmin etmeye çalışmam. Bu sadece JIT derleyicisi için kaynak kodu ... emin olabilirsiniz yeniden koruyarak kaynak kodu satırları ortaya çıkan kodu ve verimliliğini değiştirebilir, ama hepsi oldukça tahmin edilemez.
maaartinus

13

Daha fazla zeyilname. IBM'den en son Java 8 JVM'yi kullanarak denemeyi yeniden yaptım:

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)

Ve bu çok benzer sonuçlar gösteriyor:

0.374653912 s
n = 119860736
0.447778698 s
n = 119860736

(2 * i * i kullanılarak ikinci sonuçlar).

Aynı makinede çalışırken, ancak Oracle Java kullanırken ilginç bir şekilde:

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)

sonuçlar ortalama olarak biraz daha yavaştır:

0.414331815 s
n = 119860736
0.491430656 s
n = 119860736

Uzun lafın kısası: HotSpot'un küçük sürüm sayısı bile, JIT uygulamasındaki küçük farklılıkların dikkate değer etkileri olabilir.


5

İki ekleme yöntemi, biraz farklı bayt kodu oluşturur:

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

For 2 * (i * i)vs:

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

İçin 2 * i * i.

Ve böyle bir JMH karşılaştırması kullanırken :

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

}

Fark açık:

# 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

Gözlemlediğiniz şey doğrudur ve sadece kıyaslama stilinizin bir anomalisi değil (yani ısınma yok, bkz . Java'da doğru bir mikro karşılaştırmayı nasıl yazarım? )

Graal ile tekrar koşmak:

# 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

Graal genel olarak daha iyi performans gösteren, daha modern bir derleyici olduğu için sonuçların çok daha yakın olduğunu görüyorsunuz.

Bu gerçekten de JIT derleyicisinin belirli bir kod parçasını ne kadar iyi optimize edebildiğine bağlıdır ve bunun mantıklı bir nedeni yoktur.

Sitemizi kullandığınızda şunları okuyup anladığınızı kabul etmiş olursunuz: Çerez Politikası ve Gizlilik Politikası.
Licensed under cc by-sa 3.0 with attribution required.