Mengapa 2 * (i * i) lebih cepat dari 2 * i * i di Jawa?

855

Program Java berikut ini berjalan rata-rata antara 0,50 detik dan 0,55 detik:

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

Jika saya ganti 2 * (i * i)dengan 2 * i * i, dibutuhkan antara 0,60 dan 0,65 detik untuk menjalankan. Bagaimana bisa?

Saya menjalankan setiap versi program 15 kali, bergantian antara keduanya. Inilah hasilnya:

 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

Jalankan tercepat 2 * i * imemakan waktu lebih lama dari lari paling lambat 2 * (i * i). Jika mereka memiliki efisiensi yang sama, kemungkinan terjadinya ini akan kurang dari 1/2^15 * 100% = 0.00305%.

Stefan
sumber
5
Saya mendapatkan hasil yang sama (angka yang sedikit berbeda, tapi jelas terlihat dan konsisten kesenjangan, jelas lebih dari kesalahan pengambilan sampel)
Krease
29
Silakan juga lihat: stackoverflow.com/questions/504103/…
lexicore
3
@Krease Bagus kau menangkap kesalahanku. Menurut patokan baru saya berlari 2 * i * ilebih lambat. Saya akan mencoba berlari dengan Graal juga.
Jorn Vernee
5
@nullpointer Untuk mencari tahu mengapa satu lebih cepat dari yang lain, kita harus mendapatkan grafik Pembongkaran atau Ideal untuk metode-metode tersebut. Assembler sangat menyebalkan untuk dicoba dan diketahui, jadi saya mencoba untuk mendapatkan build debug OpenJDK yang dapat menampilkan grafik yang bagus.
Jorn Vernee
4
Anda dapat mengubah nama pertanyaan Anda menjadi " Mengapa i * i * 2lebih cepat daripada 2 * i * i? " Untuk kejelasan yang lebih baik bahwa masalahnya ada pada urutan operasi.
Cœur

Jawaban:

1202

Ada sedikit perbedaan dalam pemesanan bytecode.

2 * (i * i):

     iconst_2
     iload0
     iload0
     imul
     imul
     iadd

vs 2 * i * i:

     iconst_2
     iload0
     imul
     iload0
     imul
     iadd

Pada pandangan pertama ini seharusnya tidak membuat perbedaan; jika ada versi kedua lebih optimal karena menggunakan satu slot kurang.

Jadi kita perlu menggali lebih dalam ke level yang lebih rendah (JIT) 1 .

Ingat bahwa JIT cenderung membuka gulungan kecil dengan sangat agresif. Memang kami mengamati 16x membuka gulungan untuk 2 * (i * i)kasus ini:

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

Kita melihat bahwa ada 1 register yang "tumpah" ke stack.

Dan untuk 2 * i * iversinya:

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

Di sini kami mengamati lebih banyak "tumpahan" dan lebih banyak akses ke tumpukan [RSP + ...], karena hasil yang lebih menengah yang perlu dipertahankan.

Dengan demikian jawaban atas pertanyaannya sederhana: 2 * (i * i)lebih cepat daripada 2 * i * ikarena JIT menghasilkan kode perakitan yang lebih optimal untuk kasus pertama.


Tetapi tentu saja jelas bahwa versi pertama maupun kedua tidak ada gunanya; loop benar-benar bisa mendapat manfaat dari vektorisasi, karena x86-64 CPU memiliki setidaknya dukungan SSE2.

Jadi ini masalah optimizer; seperti yang sering terjadi, itu membuka gulungan terlalu agresif dan menembak dirinya sendiri di kaki, sambil kehilangan berbagai peluang lainnya.

Faktanya, CPU x86-64 modern memecah instruksi lebih lanjut menjadi micro-ops (µops) dan dengan fitur-fitur seperti pengubahan nama register, cache µop dan buffer loop, optimisasi loop membutuhkan jauh lebih banyak keahlian daripada membuka gulungan sederhana untuk kinerja optimal. Menurut panduan optimasi Agner Fog :

Keuntungan dalam kinerja karena cache µop bisa sangat besar jika panjang instruksi rata-rata lebih dari 4 byte. Metode berikut untuk mengoptimalkan penggunaan cache µop dapat dipertimbangkan:

  • Pastikan bahwa loop kritis cukup kecil untuk masuk ke dalam cache µop.
  • Sejajarkan entri loop paling penting dan entri fungsi dengan 32.
  • Hindari loop terbuka yang tidak perlu.
  • Hindari instruksi yang memiliki waktu muat tambahan
    . . .

Mengenai waktu muat tersebut - bahkan hit L1D tercepat memakan biaya 4 siklus , register ekstra dan µop, jadi ya, bahkan beberapa akses ke memori akan melukai kinerja dalam loop ketat.

Tetapi kembali ke peluang vektorisasi - untuk melihat seberapa cepatnya, kita dapat mengkompilasi aplikasi C yang mirip dengan GCC , yang langsung membuat vektorisasi (AVX2 ditunjukkan, SSE2 serupa) 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

Dengan waktu berjalan:

  • SSE: 0,24 dtk, atau 2 kali lebih cepat.
  • AVX: 0,15 dtk, atau 3 kali lebih cepat.
  • AVX2: 0,08 dtk, atau 5 kali lebih cepat.

1 Untuk mendapatkan output perakitan yang dihasilkan JIT, dapatkan JVM debug dan jalankan bersama-XX:+PrintOptoAssembly

2 Versi C dikompilasi dengan -fwrapvflag, yang memungkinkan GCC untuk memperlakukan overflow bilangan bulat yang ditandatangani sebagai pembungkus dua komplemen.

rustyx
sumber
11
Masalah terbesar yang dihadapi optimizer dalam contoh C adalah perilaku tidak terdefinisi yang dipicu oleh integer overflow yang ditandatangani. Yang, jika tidak, mungkin akan menghasilkan hanya memuat konstanta karena seluruh loop dapat dihitung pada waktu bersamaan.
Damon
44
@Damon Mengapa perilaku tidak terdefinisi menjadi masalah bagi pengoptimal? Jika pengoptimal melihatnya meluap ketika mencoba menghitung hasilnya, itu berarti ia dapat mengoptimalkannya seperti yang diinginkannya, karena perilakunya tidak terdefinisi.
13
@Runemoro: jika pengoptimal membuktikan bahwa memanggil fungsi pasti akan menghasilkan perilaku yang tidak terdefinisi, itu bisa memilih untuk menganggap bahwa fungsi tidak akan pernah dipanggil, dan tidak memancarkan tubuh untuk itu. Atau memancarkan hanya retinstruksi, atau memancarkan label dan tidak ada instruksi ret sehingga eksekusi hanya gagal. GCC sebenarnya berperilaku ini kadang-kadang ketika bertemu UB. Misalnya: mengapa ret menghilang dengan optimasi? . Anda pasti ingin mengkompilasi kode yang terbentuk dengan baik untuk memastikan asm itu waras.
Peter Cordes
8
Ini mungkin hanya bottleneck throughput uop front-end karena kode-gen tidak efisien. Bahkan tidak menggunakan LEA sebagai lubang untuk mov/ add-immediate. misalnya movl RBX, R9/ addl RBX, #8seharusnya leal ebx, [r9 + 8], 1 uop untuk menyalin dan menambahkan. Atau leal ebx, [r9 + r9 + 16]melakukan ebx = 2*(r9+8). Jadi ya, membuka gulungan sampai tumpah adalah bodoh, dan begitu juga codegen braindead naif yang tidak mengambil keuntungan dari identitas integer dan matematika integer asosiatif.
Peter Cordes
7
Vektorisasi untuk pengurangan berurutan dinonaktifkan di C2 ( bugs.openjdk.java.net/browse/JDK-8078563 ), tetapi sekarang sedang dipertimbangkan untuk mengaktifkan kembali ( bugs.openjdk.java.net/browse/JDK-8188313 ).
pron
131

Ketika multiplikasi adalah 2 * (i * i), JVM mampu memfaktorkan keluar perkalian dengan 2dari loop, menghasilkan kode yang setara tetapi lebih efisien ini:

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

tetapi ketika multiplikasi adalah (2 * i) * i, JVM tidak mengoptimalkannya karena perkalian dengan konstanta tidak lagi tepat sebelum penambahan.

Berikut adalah beberapa alasan mengapa saya pikir inilah masalahnya:

  • Menambahkan if (n == 0) n = 1pernyataan pada awal loop menghasilkan kedua versi menjadi efisien, karena memfaktorkan perkalian tidak lagi menjamin bahwa hasilnya akan sama
  • Versi yang dioptimalkan (dengan memperhitungkan keluar perkalian 2) sama persis dengan 2 * (i * i)versi

Berikut adalah kode tes yang saya gunakan untuk menarik kesimpulan ini:

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

Dan inilah hasilnya:

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

sumber
3
Saya pikir pada versi yang dioptimalkan, seharusnyan *= 2000000000;
StefansArya
4
@StefansArya - Tidak. Pertimbangkan kasus di mana batasnya adalah 4, dan kami mencoba menghitung 2*1*1 + 2*2*2 + 2*3*3. Jelas bahwa menghitung 1*1 + 2*2 + 3*3dan mengalikan dengan 2 adalah benar, sedangkan mengalikan dengan 8 tidak akan.
Martin Bonner mendukung Monica
5
Persamaan matematika persis seperti ini 2(1²) + 2(2²) + 2(3²) = 2(1² + 2² + 3²). Itu sangat sederhana dan saya hanya lupa karena kenaikan loop.
StefansArya
5
Jika Anda mencetak unit menggunakan debug jvm, ini tampaknya tidak benar. Anda akan melihat sekelompok sall ..., # 1, yang dikalikan dengan 2, dalam loop. Menariknya, versi yang lebih lambat tampaknya tidak memiliki kelipatan dalam loop.
Daniel Berlin
2
Mengapa faktor JVM keluar dari 2 2 * (i * i)tetapi bukan dari (2 * i) * i? Saya akan berpikir mereka setara (itu mungkin asumsi saya yang buruk). Jika demikian, bukankah JVM akan mengkanoniskan ekspresi sebelum mengoptimalkan?
RedSpikeyThing
41

Kode byte: https://cs.nyu.edu/courses/fall00/V22.0201-001/jvm2.html Kode byte Penampil: https://github.com/Konloch/bytecode-viewer

Di JDK saya (Windows 10 64 bit, 1.8.0_65-b17) saya dapat mereproduksi dan menjelaskan:

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

Keluaran:

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

Jadi kenapa? Kode byte adalah ini:

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

Perbedaannya adalah: Dengan kurung ( 2 * (i * i)):

  • dorong tumpukan stack
  • dorong lokal di stack
  • dorong lokal di stack
  • gandakan bagian atas tumpukan
  • gandakan bagian atas tumpukan

Tanpa tanda kurung ( 2 * i * i):

  • dorong tumpukan stack
  • dorong lokal di stack
  • gandakan bagian atas tumpukan
  • dorong lokal di stack
  • gandakan bagian atas tumpukan

Memuat semua pada tumpukan dan kemudian bekerja kembali lebih cepat daripada beralih antara meletakkan di atas tumpukan dan mengoperasikannya.

DSchmidt
sumber
Tetapi mengapa push-push-multiply-multiply lebih cepat daripada push-multiply-push-multiply?
m0skit0
35

Kasperd bertanya dalam komentar tentang jawaban yang diterima:

Contoh Java dan C menggunakan nama register yang sangat berbeda. Apakah kedua contoh menggunakan 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

Saya tidak memiliki reputasi yang cukup untuk menjawab ini di komentar, tetapi ini adalah ISA yang sama. Perlu ditunjukkan bahwa versi GCC menggunakan logika integer 32-bit dan versi kompilasi JVM menggunakan logika integer 64-bit secara internal.

R8 hingga R15 hanyalah register X86_64 baru . EAX ke EDX adalah bagian bawah register tujuan umum RAX ke RDX. Bagian penting dalam jawabannya adalah bahwa versi GCC tidak terbuka. Ini hanya menjalankan satu putaran loop per loop kode mesin yang sebenarnya. Sementara versi JVM memiliki 16 putaran loop dalam satu loop fisik (berdasarkan jawaban rustyx, saya tidak menafsirkan ulang perakitan). Ini adalah salah satu alasan mengapa ada lebih banyak register yang digunakan karena loop body sebenarnya 16 kali lebih lama.

Bingung
sumber
2
Sayang sekali gcc tidak menyadari itu bisa menenggelamkan *2keluar dari loop. Meskipun dalam kasus ini, itu bahkan bukan kemenangan untuk melakukan itu, karena melakukannya secara gratis dengan LEA. Pada Intel CPU, lea eax, [rax+rcx*2]memiliki latensi 1c yang sama dengan add eax,ecx. Namun, pada CPU AMD indeks skala mana pun meningkatkan latensi LEA menjadi 2 siklus. Jadi rantai ketergantungan loop-carry memanjang menjadi 2 siklus, menjadi hambatan pada Ryzen. ( imul ecx,edxthroughput adalah 1 per jam pada Ryzen, dan pada Intel).
Peter Cordes
31

Meskipun tidak terkait langsung dengan lingkungan pertanyaan, hanya untuk keingintahuan, saya melakukan tes yang sama pada .NET Core 2.1, x64, mode rilis.

Ini adalah hasil yang menarik, mengkonfirmasikan phonomena serupa (sebaliknya) terjadi di sisi gelap gaya. Kode:

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

Hasil:

2 * (i * i)

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

2 * i * i

  • hasil: 119860736, 417 ms
  • hasil: 119860736, 417 ms
  • hasil: 119860736, 417 ms
  • hasil: 119860736, 418 ms
  • hasil: 119860736, 418 ms
  • hasil: 119860736, 417 ms
  • hasil: 119860736, 418 ms
  • hasil: 119860736, 416 ms
  • hasil: 119860736, 417 ms
  • hasil: 119860736, 418 ms
Ünsal Ersöz
sumber
1
Meskipun ini bukan jawaban untuk pertanyaan, itu menambah nilai. Yang sedang berkata, jika ada sesuatu yang vital untuk posting Anda, harap sebariskan itu di postingan daripada menautkan ke sumber daya di luar situs. Tautan mati.
Jared Smith
1
@JaredSmith Terima kasih atas umpan baliknya. Mengingat tautan yang Anda sebutkan adalah tautan "hasil", gambar itu bukan sumber di luar situs. Saya mengunggahnya ke stackoverflow melalui panelnya sendiri.
Ünsal Ersöz
1
Itu adalah tautan ke imgur, jadi ya, memang, tidak masalah bagaimana Anda menambahkan tautan. Saya gagal melihat apa yang begitu sulit tentang copy-paste beberapa output konsol.
Jared Smith
5
Kecuali ini sebaliknya
leppie
2
@ Sam masih di domain imgur.com, yang berarti hanya akan bertahan selama imgur.
p91paul
21

Saya mendapat hasil yang serupa:

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

Saya mendapat hasil SAMA jika kedua loop berada di program yang sama, atau masing-masing dalam file .java terpisah .class, dieksekusi pada menjalankan yang terpisah.

Akhirnya, inilah javap -c -v <.java>dekompilasi dari masing-masing:

     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)
paulsm4
sumber
1
Jawaban yang lebih baik dan mungkin Anda dapat memilih untuk membatalkan penghapusan - stackoverflow.com/a/53452836/1746118 ... Catatan tambahan - Saya bukan yang downvoter.
Naman
@nullpointer - Saya setuju. Saya pasti akan memilih untuk membatalkan penghapusan, jika saya bisa. Saya juga ingin "menggandakan dukungan" Stefan karena memberikan definisi kuantitatif "signifikan"
paulsm4
Yang itu dihapus sendiri karena mengukur hal yang salah - lihat komentar penulis tentang pertanyaan di atas
Krease
2
Dapatkan debug debug dan jalankan dengan -XX:+PrintOptoAssembly. Atau cukup gunakan vtune atau sama.
rustyx
1
@ rustyx - Jika masalahnya adalah implementasi JIT ... lalu "dapatkan versi debug" DARI JRE YANG BERBEDA SEPENUHNYA tidak akan membantu. Namun demikian: kedengarannya seperti apa yang Anda temukan di atas dengan pembongkaran JIT Anda pada JRE Anda juga menjelaskan perilaku pada JRE OP dan milik saya. Dan juga menjelaskan mengapa JRE lain berperilaku "berbeda". +1: terima kasih atas kerja detektif yang luar biasa!
paulsm4
18

Pengamatan menarik menggunakan Java 11 dan mematikan loop membuka gulungan dengan opsi VM berikut:

-XX:LoopUnrollLimit=0

Pengulangan dengan 2 * (i * i)ekspresi menghasilkan kode asli 1 yang lebih ringkas :

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

dibandingkan dengan 2 * i * iversi:

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

Versi 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)

Hasil patok banding:

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

Kode sumber patokan:

@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 - Opsi VM yang digunakan: -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:LoopUnrollLimit=0

Oleksandr Pyrohov
sumber
2
Wow, itu beberapa asde braindead. Alih-alih menambah i sebelum menyalinnya untuk menghitung 2*i, ia melakukannya setelah itu sehingga membutuhkan add r11d,2instruksi tambahan . (Plus ia melewatkan add same,samelubang intip bukan shloleh 1 (tambahkan berjalan di lebih banyak port). Ia juga merindukan lubang intip LEA untuk x*2 + 2( lea r11d, [r8*2 + 2]) jika itu benar-benar ingin melakukan hal-hal dalam urutan itu untuk beberapa alasan penjadwalan instruksi gila. Kita sudah bisa melihat dari versi membuka gulungan yang kehilangan LEA adalah biaya banyak uops, sama seperti keduanya loop di sini
Peter Cordes
2
lea eax, [rax + r11 * 2]akan menggantikan 2 instruksi (di kedua loop) jika kompiler JIT punya waktu untuk mencari optimasi dalam loop yang berjalan lama. Kompilator masa depan yang baik mana pun akan menemukannya. (Kecuali mungkin menyetel hanya untuk AMD, di mana LEA skala indeks memiliki 2 siklus latensi jadi mungkin tidak sepadan.)
Peter Cordes
15

Saya mencoba JMH menggunakan pola dasar default: Saya juga menambahkan versi yang dioptimalkan berdasarkan penjelasan 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;
  }
}

Hasilnya ada di sini:

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

Di PC saya ( Core i7 860 - tidak ada artinya selain membaca di smartphone saya):

  • n += i*ilalu n*2yang pertama
  • 2 * (i * i) adalah yang kedua.

JVM jelas tidak mengoptimalkan dengan cara yang sama dengan manusia (berdasarkan jawaban Runemoro).

Sekarang, baca bytecode: javap -c -v ./target/classes/org/sample/MyBenchmark.class

Saya bukan ahli bytecode, tetapi kita iload_2sebelum kita imul: di situlah Anda mendapatkan perbedaan: Saya dapat menganggap bahwa JVM mengoptimalkan pembacaan idua kali ( isudah ada di sini, dan tidak perlu memuatnya lagi) sementara di 2*i*idalamnya bisa ' t.

Tidak ada data ditemukan
sumber
4
Bytecode AFAICT sangat tidak relevan untuk kinerja, dan saya tidak akan mencoba untuk memperkirakan apa yang lebih cepat berdasarkan itu. Itu hanya kode sumber untuk kompiler JIT ... tentu saja dapat mempertahankan penataan ulang baris kode sumber mengubah kode yang dihasilkan dan efisiensinya, tetapi itu semua sangat tidak dapat diprediksi.
maaartinus
13

Lebih dari sebuah tambahan. Saya melakukan repro percobaan menggunakan Java 8 JVM terbaru dari 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)

Dan ini menunjukkan hasil yang sangat mirip:

0.374653912 s
n = 119860736
0.447778698 s
n = 119860736

(hasil kedua menggunakan 2 * i * i).

Cukup menarik, ketika berjalan di mesin yang sama, tetapi menggunakan 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)

hasilnya rata-rata sedikit lebih lambat:

0.414331815 s
n = 119860736
0.491430656 s
n = 119860736

Singkat cerita: bahkan nomor versi kecil masalah HotSpot di sini, karena perbedaan halus dalam implementasi JIT dapat memiliki efek penting.

GhostCat
sumber
5

Dua metode penambahan memang menghasilkan kode byte yang sedikit berbeda:

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

Untuk 2 * (i * i)vs:

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

Untuk 2 * i * i.

Dan saat menggunakan tolok ukur JMH seperti ini:

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

}

Perbedaannya jelas:

# 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

Apa yang Anda amati benar, dan bukan hanya anomali dari gaya pembandingan Anda (yaitu, tidak ada pemanasan, lihat Bagaimana cara saya menulis pembandingan mikro yang benar di Jawa? )

Berlari lagi dengan 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

Anda melihat bahwa hasilnya jauh lebih dekat, yang masuk akal, karena Graal adalah kompiler yang berkinerja lebih baik, lebih modern, secara keseluruhan.

Jadi ini benar-benar hanya seberapa baik kompiler JIT dapat mengoptimalkan sepotong kode tertentu, dan tidak selalu memiliki alasan logis untuk itu.

Jorn Vernee
sumber