Mengapa jika (variable1% variable2 == 0) tidak efisien?

179

Saya baru mengenal java, dan menjalankan beberapa kode tadi malam, dan ini benar-benar mengganggu saya. Saya sedang membangun program sederhana untuk menampilkan setiap output X dalam loop untuk, dan saya melihat penurunan besar-besaran dalam kinerja, ketika saya menggunakan modulus sebagai variable % variablevs variable % 5000atau yang lainnya. Dapatkah seseorang menjelaskan kepada saya mengapa ini dan apa yang menyebabkannya? Jadi saya bisa lebih baik ...

Ini kode "efisien" (maaf jika saya salah sintaks, saya tidak ada di komputer dengan kode itu sekarang)

long startNum = 0;
long stopNum = 1000000000L;

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

Ini adalah "kode tidak efisien"

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

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

Pikiran Anda, saya punya variabel tanggal untuk mengukur perbedaan, dan setelah itu menjadi cukup lama, yang pertama mengambil 50 ms sementara yang lain mengambil 12 detik atau sesuatu seperti itu. Anda mungkin harus menambah stopNumatau mengurangi progressCheckjika PC Anda lebih efisien daripada milik saya atau tidak.

Saya mencari pertanyaan ini di web, tetapi saya tidak dapat menemukan jawaban, mungkin saya hanya tidak menanyakannya dengan benar.

EDIT: Saya tidak berharap pertanyaan saya begitu populer, saya menghargai semua jawaban. Saya melakukan benchmark pada setiap setengah waktu yang diambil, dan kode yang tidak efisien memakan waktu lebih lama, 1/4 detik vs 10 detik. Memang mereka menggunakan println, tetapi mereka berdua melakukan jumlah yang sama, jadi saya tidak akan membayangkan itu akan banyak condong, terutama karena perbedaannya berulang. Adapun jawabannya, karena saya baru di Jawa, saya akan membiarkan suara memutuskan untuk sekarang jawaban mana yang terbaik. Saya akan mencoba mengambilnya pada hari Rabu.

EDIT2: Saya akan membuat tes lain malam ini, di mana alih-alih modulus, itu hanya menambah variabel, dan ketika mencapai progressCheck, itu akan melakukan satu, dan kemudian mengatur ulang variabel itu ke 0. untuk opsi ke-3.

EDIT3.5:

Saya menggunakan kode ini, dan di bawah ini saya akan menunjukkan hasil saya .. Terima kasih SEMUA untuk bantuan yang luar biasa! Saya juga mencoba membandingkan nilai pendek dari panjang ke 0, jadi semua cek baru saya terjadi "65536" kali sehingga sama dengan pengulangan.

public class Main {


    public static void main(String[] args) {

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

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

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

        }

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

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

Hasil:

  • tetap = 874 ms (biasanya sekitar 1000 ms, tetapi lebih cepat karena kekuatannya 2)
  • variabel = 8590 ms
  • variabel akhir = 1944 ms (Apakah ~ 1000ms saat menggunakan 50.000)
  • kenaikan = 1904 ms
  • Konversi Pendek = 679 ms

Tidak cukup mengejutkan, karena kurangnya pembagian, Konversi Pendek 23% lebih cepat daripada cara "cepat". Ini menarik untuk dicatat. Jika Anda perlu menunjukkan atau membandingkan sesuatu setiap 256 kali (atau sekitar sana) Anda dapat melakukan ini, dan gunakan

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

SATU FINAL MENARIK CATATAN, menggunakan modulus pada "Final menyatakan Variabel" dengan 65536 (bukan angka cantik) adalah setengah kecepatan (lebih lambat) dari nilai tetap. Di mana sebelumnya, benchmarking mendekati kecepatan yang sama.

Robert Cotterman
sumber
29
Sebenarnya saya mendapat hasil yang sama. Di mesin saya, loop pertama berjalan sekitar 1,5 detik dan yang kedua berjalan sekitar 9 detik. Jika saya tambahkan finaldi depan progressCheckvariabel, keduanya berjalan pada kecepatan yang sama lagi. Itu membuat saya percaya bahwa kompiler atau JIT berhasil mengoptimalkan loop ketika ia tahu itu progressCheckkonstan.
marstran
24
Pembagian oleh konstanta dapat dengan mudah dikonversi menjadi perkalian dengan invers multiplikasi . Pembagian dengan variabel tidak bisa. Dan pembagian 32-bit lebih cepat daripada pembagian 64-bit pada x86
phuclv
2
@ phuclv note Pembagian 32-bit bukan masalah di sini, ini adalah operasi sisa 64-bit dalam kedua kasus
user85421
4
@RobertCotterman jika Anda mendeklarasikan variabel sebagai final, kompiler akan membuat bytecode yang sama dengan menggunakan konstanta (gerhana / Java 11) ((walaupun menggunakan satu slot memori lagi untuk variabel))
user85421

Jawaban:

139

Anda mengukur rintisan OSR (on-stack replacement) .

OSR stub adalah versi khusus dari metode yang dikompilasi yang ditujukan khusus untuk mentransfer eksekusi dari mode yang ditafsirkan ke kode yang dikompilasi ketika metode sedang berjalan.

Rintisan OSR tidak seoptimal metode biasa, karena mereka membutuhkan tata letak bingkai yang kompatibel dengan kerangka yang ditafsirkan. Saya sudah menunjukkan ini dalam jawaban berikut: 1 , 2 , 3 .

Hal serupa juga terjadi di sini. Sementara "kode tidak efisien" menjalankan loop panjang, metode ini dikompilasi khusus untuk penggantian on-stack tepat di dalam loop. Status ditransfer dari frame yang ditafsirkan ke metode yang dikompilasi OSR, dan status ini termasuk progressCheckvariabel lokal. Pada titik ini JIT tidak dapat menggantikan variabel dengan konstanta, dan dengan demikian tidak dapat menerapkan optimasi tertentu seperti pengurangan kekuatan .

Secara khusus ini berarti JIT tidak menggantikan pembagian integer dengan perkalian . (Lihat Mengapa GCC menggunakan perkalian dengan angka ganjil dalam mengimplementasikan divisi integer? Untuk trik asm dari kompiler yang lebih dulu, ketika nilainya adalah konstanta waktu kompilasi setelah inlining / propagasi konstan, jika optimasi tersebut diaktifkan . Sebuah hak literal integer dalam %ekspresi juga akan dioptimalkan oleh gcc -O0, mirip dengan di sini di mana itu dioptimalkan oleh JITer bahkan dalam rintisan OSR.)

Namun, jika Anda menjalankan metode yang sama beberapa kali, yang kedua dan selanjutnya akan menjalankan kode biasa (non-OSR), yang sepenuhnya dioptimalkan. Berikut ini adalah tolok ukur untuk membuktikan teorinya ( menggunakan JMH ):

@State(Scope.Benchmark)
public class Div {

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

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

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

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

Dan hasilnya:

# Benchmark: bench.Div.divConst

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


# Benchmark: bench.Div.divVar

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

Iterasi pertama divVarmemang jauh lebih lambat, karena rintisan OSR yang dikompilasi secara tidak efisien. Tetapi segera setelah metode dijalankan kembali dari awal, versi baru yang tidak dibatasi dijalankan yang memanfaatkan semua optimisasi kompiler yang tersedia.

apangin
sumber
5
Saya ragu memilih ini. Di satu sisi, itu terdengar seperti cara rumit untuk mengatakan "Anda mengacaukan tolok ukur Anda, membaca sesuatu tentang JIT". Di sisi lain, saya ingin tahu mengapa Anda begitu yakin bahwa OSR adalah poin yang relevan di sini. Maksud saya, melakukan tolok ukur (mikro) yang melibatkan System.out.printlnhampir pasti menghasilkan hasil sampah, dan fakta bahwa kedua versi sama-sama cepat tidak harus melakukan apa pun dengan OSR khususnya , sejauh yang saya tahu ..
Marco13
2
(Saya ingin tahu dan ingin memahami ini. Saya harap komentarnya tidak mengganggu, mungkin menghapusnya nanti, tetapi 1:) Tautannya agak meragukan - loop kosong juga dapat dioptimalkan sepenuhnya. Yang kedua lebih mirip dengan yang itu. Tetapi sekali lagi, tidak jelas mengapa Anda menghubungkan perbedaannya dengan OSR secara khusus . Saya hanya akan mengatakan: Pada titik tertentu, metode ini JITed dan menjadi lebih cepat. Menurut pemahaman saya, OSR hanya menyebabkan penggunaan kode final yang dioptimalkan menjadi (kira-kira) ~ "ditangguhkan ke pass optimasi berikutnya". (lanjutan ...)
Marco13
1
(lanjutan :) Kecuali Anda secara khusus menganalisis log hotspot, Anda tidak dapat mengatakan apakah perbedaannya disebabkan oleh membandingkan kode JITed dan un-JITed, atau dengan membandingkan kode JITed dan OSR-stub-code. Dan Anda tentu tidak bisa mengatakan itu dengan pasti ketika pertanyaan tidak mengandung kode asli atau tolok ukur JMH lengkap. Jadi berargumen bahwa perbedaan itu disebabkan oleh suara OSR, bagi saya, spesifik tidak tepat (dan "tidak dibenarkan") dibandingkan dengan mengatakan bahwa itu disebabkan oleh JIT pada umumnya. (Jangan tersinggung - Saya hanya ingin tahu ...)
Marco13
4
@ Marco13 ada heuristik sederhana: tanpa aktivitas JIT, setiap %operasi akan memiliki bobot yang sama, karena eksekusi yang dioptimalkan hanya mungkin, well, jika pengoptimal melakukan pekerjaan yang sebenarnya. Jadi fakta bahwa satu varian loop secara signifikan lebih cepat daripada yang lain membuktikan keberadaan pengoptimal dan lebih lanjut membuktikan bahwa itu gagal untuk mengoptimalkan salah satu loop ke tingkat yang sama seperti yang lain (dalam metode yang sama!). Karena jawaban ini membuktikan kemampuan mengoptimalkan kedua loop ke tingkat yang sama, pasti ada sesuatu yang menghambat optimasi. Dan itu OSR dalam 99,9% dari semua kasus
Holger
4
@ Marco13 Itu adalah "tebakan yang dididik" berdasarkan pengetahuan tentang HotSpot Runtime dan pengalaman menganalisis masalah serupa sebelumnya. Putaran yang panjang seperti itu hampir tidak bisa dikompilasi dengan cara selain OSR, terutama dalam benchmark buatan tangan yang sederhana. Sekarang, ketika OP telah memposting kode lengkap, saya hanya dapat mengkonfirmasi alasan sekali lagi dengan menjalankan kode -XX:+PrintCompilation -XX:+TraceNMethodInstalls.
apangin
42

Sebagai tindak lanjut dari komentar @ phuclv , saya memeriksa kode yang dihasilkan oleh JIT 1 , hasilnya adalah sebagai berikut:

untuk variable % 5000(pembagian dengan konstan):

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

untuk variable % variable:

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

Karena pembagian selalu membutuhkan waktu lebih lama daripada perkalian, snipet kode terakhir kurang berkinerja.

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)

1 - Opsi VM yang digunakan: -XX:+UnlockDiagnosticVMOptions -XX:CompileCommand=print,src/java/Main.main

Oleksandr Pyrohov
sumber
14
Untuk memberikan urutan besarnya pada "lebih lambat", untuk x86_64: imuladalah 3 siklus, idivantara 30 dan 90 siklus. Jadi pembagian integer antara 10x dan 30x lebih lambat dari perkalian integer.
Matthieu M.
2
Bisakah Anda menjelaskan apa artinya semua itu bagi pembaca yang tertarik, tetapi jangan berbicara assembler?
Nico Haase
7
@NicoHaase Dua baris komentar adalah satu-satunya yang penting. Di bagian pertama, kode melakukan perkalian bilangan bulat, sedangkan bagian kedua, kode melakukan pembagian bilangan bulat. Jika Anda berpikir tentang melakukan perkalian dan pembagian dengan tangan, ketika Anda mengalikannya biasanya Anda melakukan banyak perkalian kecil dan kemudian satu set tambahan besar, tetapi pembagian adalah pembagian kecil, perkalian kecil, pengurangan, pengurangan, dan ulangi. Division lambat karena pada dasarnya kamu melakukan banyak perkalian.
MBraedley
4
@ MBraedley terima kasih atas masukan Anda, tetapi penjelasan seperti itu harus ditambahkan ke jawaban itu sendiri dan tidak disembunyikan di bagian komentar
Nico Haase
6
@ MBraedley: Lebih tepatnya, perkalian dalam CPU modern cepat karena produk parsial independen dan dengan demikian dapat dihitung secara terpisah, sedangkan setiap tahap divisi tergantung pada tahap sebelumnya.
supercat
26

Seperti yang telah dicatat orang lain, operasi modulus umum memerlukan pembagian yang harus dilakukan. Dalam beberapa kasus, divisi dapat diganti (oleh kompiler) dengan perkalian. Namun keduanya bisa lambat dibandingkan dengan penambahan / pengurangan. Oleh karena itu, kinerja terbaik dapat diharapkan oleh sesuatu di sepanjang garis ini:

long progressCheck = 50000;

long counter = progressCheck;

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

(Sebagai upaya minor optmiziation kami menggunakan pra-pengurangan down-counter di sini karena pada banyak arsitektur membandingkan 0segera setelah operasi aritmatika biaya tepat 0 instruksi / siklus CPU karena bendera ALU sudah ditetapkan dengan tepat oleh operasi sebelumnya. Pengoptimalan yang layak kompiler akan, bagaimanapun, melakukan optimasi itu secara otomatis walaupun Anda menulis if (counter++ == 50000) { ... counter = 0; }.)

Perhatikan bahwa seringkali Anda tidak benar-benar menginginkan / memerlukan modulus, karena Anda tahu bahwa penghitung putaran Anda (i ) atau apa pun yang hanya bertambah 1, dan Anda benar-benar tidak peduli dengan sisa aktual yang akan diberikan modulus kepada Anda, lihat saja jika penghitung yang bertambah satu demi satu bernilai.

'Trik' lain adalah menggunakan kekuatan / nilai dua batas, misalnya progressCheck = 1024;. Modulus kekuatan dua dapat dengan cepat dihitung melalui bitwise and, yaitu if ( (i & (1024-1)) == 0 ) {...}. Ini harus cukup cepat juga, dan mungkin pada beberapa arsitektur mengungguli eksplisit di counteratas.

JimmyB
sumber
3
Kompiler yang pintar akan membalikkan loop di sini. Atau Anda bisa melakukannya di sumbernya. The if()tubuh menjadi tubuh luar loop, dan hal-hal di luar if()menjadi badan loop batin yang berjalan untuk min(progressCheck, stopNum-i)iterasi. Jadi pada awalnya, dan setiap kali countermencapai 0, Anda lakukan long next_stop = i + min(progressCheck, stopNum-i);untuk mengatur for(; i< next_stop; i++) {}loop. Dalam hal ini bahwa loop batin kosong dan mudah-mudahan harus mengoptimalkan sepenuhnya, Anda dapat melakukannya di sumber dan membuatnya mudah untuk JITer, mengurangi loop Anda menjadi i + = 50k.
Peter Cordes
2
Tapi ya, secara umum down-counter adalah teknik efisien yang bagus untuk hal-hal jenis fizzbuzz / progresscheck.
Peter Cordes
Aku ditambahkan ke pertanyaan saya, dan melakukan kenaikan, yang --counterhanya secepat versi increment saya, tapi kurang code.also itu 1 lebih rendah dari yang seharusnya, saya ingin tahu apakah itu harus counter--untuk mendapatkan nomor yang tepat yang Anda inginkan , tidak jauh berbeda
Robert Cotterman
@PeterCordes Kompiler pintar hanya akan mencetak angka, tidak ada loop sama sekali. (Saya pikir beberapa tolok ukur yang hanya sedikit lebih sepele mulai gagal seperti itu mungkin 10 tahun yang lalu.)
Peter - Reinstate Monica
2
@RobertCotterman Ya, --countertidak aktif satu per satu. counter--akan memberi Anda persis progressCheckjumlah iterasi (atau Anda dapat mengatur progressCheck = 50001;tentu saja).
JimmyB
4

Saya juga terkejut dengan melihat kinerja kode di atas. Ini semua tentang waktu yang diambil oleh kompiler untuk menjalankan program sesuai dengan variabel yang dideklarasikan. Dalam contoh kedua (tidak efisien):

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

Anda sedang melakukan operasi modulus antara dua variabel. Di sini, kompiler harus memeriksa nilai stopNumdanprogressCheck untuk pergi ke blok memori spesifik yang terletak untuk variabel-variabel ini setiap kali setelah setiap iterasi karena merupakan variabel dan nilainya mungkin berubah.

Itu sebabnya setelah setiap kompilasi iterasi pergi ke lokasi memori untuk memeriksa nilai terbaru dari variabel. Oleh karena itu pada waktu kompilasi, kompiler tidak dapat membuat kode byte yang efisien.

Pada contoh kode pertama, Anda melakukan operator modulus antara variabel dan nilai numerik konstan yang tidak akan berubah dalam eksekusi dan kompiler tidak perlu memeriksa nilai nilai numerik itu dari lokasi memori. Itu sebabnya kompiler dapat membuat kode byte yang efisien. Jika Anda mendeklarasikan progressChecksebagai finalatau sebagai final staticvariabel, maka pada saat run-time / compile-time compiler tahu bahwa itu adalah variabel final dan nilainya tidak akan berubah, maka compiler ganti dengan progressCheckdengan 50000dalam kode:

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

Sekarang Anda dapat melihat bahwa kode ini juga terlihat seperti contoh kode pertama (efisien). Kinerja kode pertama dan seperti yang kami sebutkan di atas kedua kode akan bekerja secara efisien. Tidak akan ada banyak perbedaan dalam waktu eksekusi dari salah satu contoh kode.

Bishal Dubey
sumber
1
Ada perbedaan besar, meskipun saya melakukan operasi satu triliun kali, jadi lebih dari 1 triliun operasi menghemat 89% waktu untuk melakukan kode "efisien". keberatan Anda jika Anda hanya melakukannya beberapa ribu kali, berbicara tentang perbedaan kecil, mungkin itu bukan masalah besar. maksud saya lebih dari 1000 operasi itu akan menghemat 1 juta 7 detik.
Robert Cotterman
1
@Bishal Dubey "Tidak akan ada banyak perbedaan dalam waktu eksekusi kedua kode." Apakah Anda membaca pertanyaannya?
Grant Foster
"Itu sebabnya setelah setiap kompilasi iterasi pergi ke lokasi memori untuk memeriksa nilai terbaru dari variabel" - Kecuali variabel dinyatakan volatile'kompiler' tidak akan membaca nilainya dari RAM berulang-ulang.
JimmyB