Guru sains komputer kami pernah mengatakan bahwa untuk beberapa alasan lebih efisien menghitung mundur daripada menghitung mundur. Sebagai contoh jika Anda perlu menggunakan loop UNTUK dan indeks loop tidak digunakan di suatu tempat (seperti mencetak garis N * ke layar) Maksud saya kode seperti ini:
for (i = N; i >= 0; i--)
putchar('*');
lebih baik dari:
for (i = 0; i < N; i++)
putchar('*');
Benarkah itu benar? Dan jika demikian, adakah yang tahu mengapa?
c
performance
loops
Bob
sumber
sumber
putchar
menggunakan 99,9999% dari waktu (memberi atau menerima).i
tidak ditandatangani, loop pertama adalah yang tak terbatas?Jawaban:
Di zaman kuno, ketika komputer masih pecah dari silika menyatu dengan tangan, ketika mikrokontroler 8-bit berkeliaran di Bumi, dan ketika guru Anda masih muda (atau guru guru Anda masih muda), ada instruksi mesin yang umum disebut decrement dan skip jika nol (DSZ). Programmer hotshot assembly menggunakan instruksi ini untuk mengimplementasikan loop. Kemudian mesin mendapat instruksi yang lebih bagus, tetapi masih ada beberapa prosesor yang lebih murah untuk membandingkan sesuatu dengan nol daripada membandingkannya dengan yang lain. (Memang benar bahkan pada beberapa mesin RISC modern, seperti PPC atau SPARC, yang memesan seluruh register agar selalu nol.)
Jadi, jika Anda memasang loop untuk membandingkan dengan nol, bukan
N
, apa yang mungkin terjadi?Apakah perbedaan ini cenderung menghasilkan peningkatan terukur pada program nyata pada prosesor modern yang rusak? Sangat tidak mirip. Bahkan, saya akan terkesan jika Anda bisa menunjukkan peningkatan yang terukur bahkan pada microbenchmark.
Ringkasan: Aku memukul gurumu terbalik! Anda seharusnya tidak belajar fakta pseudo-usang tentang bagaimana mengatur loop. Anda harus belajar bahwa hal terpenting dari loop adalah memastikan bahwa loop itu berakhir , menghasilkan jawaban yang benar , dan mudah dibaca . Saya berharap gurumu akan fokus pada hal-hal penting dan bukan mitologi.
sumber
putchar
dibutuhkan banyak pesanan lebih besar dari loop overhead.j=N-i
menunjukkan bahwa kedua loop adalah setara.Inilah yang mungkin terjadi pada beberapa perangkat keras tergantung pada apa yang dapat disimpulkan oleh kompiler tentang kisaran angka yang Anda gunakan: dengan putaran yang bertambah Anda harus menguji
i<N
setiap kali putaran loop. Untuk versi penurunan, flag carry (ditetapkan sebagai efek samping dari pengurangan) dapat secara otomatis memberi tahu Anda jikai>=0
. Itu menghemat tes per putaran waktu loop.Pada kenyataannya, pada perangkat keras prosesor pipelined modern, hal ini hampir pasti tidak relevan karena tidak ada pemetaan 1-1 sederhana dari instruksi ke siklus jam. (Meskipun saya bisa membayangkannya muncul jika Anda melakukan hal-hal seperti menghasilkan sinyal video tepat waktu dari mikrokontroler. Tetapi, Anda tetap akan menulis dalam bahasa assembly.)
sumber
Dalam set instruksi Intel x86, membangun loop untuk menghitung mundur ke nol biasanya dapat dilakukan dengan instruksi yang lebih sedikit daripada loop yang menghitung hingga kondisi keluar yang tidak nol. Secara khusus, register ECX secara tradisional digunakan sebagai penghitung loop dalam x86 asm, dan set instruksi Intel memiliki instruksi jcxz jump khusus yang menguji register ECX untuk nol dan melompat berdasarkan pada hasil tes.
Namun, perbedaan kinerja akan diabaikan kecuali loop Anda sudah sangat sensitif terhadap jumlah siklus jam. Menghitung mundur ke nol mungkin mencukur 4 atau 5 siklus clock dari setiap iterasi loop dibandingkan dengan menghitung, jadi itu benar-benar lebih baru daripada teknik yang berguna.
Juga, kompiler pengoptimal yang baik hari ini harus dapat mengubah kode sumber loop count up Anda menjadi mundur ke nol kode mesin (tergantung pada bagaimana Anda menggunakan variabel indeks loop) sehingga benar-benar tidak ada alasan untuk menulis loop Anda di cara aneh hanya dengan memeras satu atau dua siklus di sana-sini.
sumber
Iya..!!
Menghitung dari N ke 0 sedikit lebih cepat dari Menghitung dari 0 hingga N dalam arti bagaimana perangkat keras akan menangani perbandingan ..
Perhatikan perbandingan di setiap loop
Sebagian besar prosesor memiliki perbandingan dengan nol instruksi..jadi yang pertama akan diterjemahkan ke kode mesin sebagai:
Tapi yang kedua perlu memuat N dari Memory setiap kali
Jadi bukan karena menghitung mundur atau naik .. Tapi karena bagaimana kode Anda akan diterjemahkan ke dalam kode mesin ..
Jadi menghitung dari 10 hingga 100 sama dengan menghitung bentuk 100 hingga 10
Tetapi menghitung dari i = 100 ke 0 lebih cepat daripada dari i = 0 hingga 100 - dalam banyak kasus
Dan menghitung dari i = N ke 0 lebih cepat daripada dari i = 0 hingga N
sumber
Dalam C ke psudo-assembly:
berubah menjadi
sementara:
berubah menjadi
Perhatikan kurangnya perbandingan dalam psudo-assembly kedua. Pada banyak arsitektur ada bendera yang diatur oleh operasi aritmatik (menambah, mengurangi, mengalikan, membagi, menambah, mengurangi) yang dapat Anda gunakan untuk melompat. Ini sering memberi Anda apa yang pada dasarnya perbandingan hasil operasi dengan 0 secara gratis. Bahkan pada banyak arsitektur
secara semantik sama dengan
Juga, bandingkan dengan 10 pada contoh saya bisa menghasilkan kode yang lebih buruk. 10 mungkin harus tinggal dalam register, jadi jika persediaannya sedikit, biayanya dan dapat menghasilkan kode tambahan untuk memindahkan barang-barang atau memuat ulang 10 setiap kali melalui loop.
Compiler kadang-kadang dapat mengatur ulang kode untuk mengambil keuntungan dari ini, tetapi seringkali sulit karena mereka sering tidak dapat memastikan bahwa membalikkan arah melalui loop secara semantik setara.
sumber
i
tidak digunakan dalam loop, jelas Anda bisa membalikkannya bukan?Hitung mundur lebih cepat jika seperti ini:
karena
someObject.getAllObjects.size()
dijalankan sekali di awal.Tentu, perilaku serupa dapat dicapai dengan memanggil
size()
keluar dari lingkaran, seperti yang disebutkan Peter:sumber
exec
.Mungkin. Tetapi jauh lebih dari 99% dari waktu itu tidak masalah, jadi Anda harus menggunakan tes yang paling 'masuk akal' untuk mengakhiri perulangan, dan dengan masuk akal, saya maksudkan bahwa dibutuhkan paling sedikit pemikiran oleh pembaca untuk mencari tahu apa yang dilakukan loop (termasuk apa yang membuatnya berhenti). Buat kode Anda cocok dengan model mental (atau didokumentasikan) dari apa yang dilakukan kode.
Jika pengulangan bekerja dengan cara yang melalui array (atau daftar, atau apa pun), penghitung kenaikan akan sering lebih cocok dengan bagaimana pembaca mungkin memikirkan apa yang dilakukan pengulangan - beri kode pengulangan Anda dengan cara ini.
Tetapi jika Anda bekerja melalui wadah yang memiliki
N
item, dan menghapus item saat Anda pergi, mungkin lebih masuk akal secara kognitif untuk menghitung.Sedikit lebih detail pada 'mungkin' dalam jawabannya:
Memang benar bahwa pada sebagian besar arsitektur, pengujian untuk perhitungan yang menghasilkan nol (atau berubah dari nol menjadi negatif) tidak memerlukan instruksi pengujian eksplisit - hasilnya dapat diperiksa secara langsung. Jika Anda ingin menguji apakah suatu perhitungan menghasilkan angka lain, aliran instruksi umumnya harus memiliki instruksi eksplisit untuk menguji nilai itu. Namun, terutama dengan CPU modern, tes ini biasanya akan menambah waktu tambahan tingkat kebisingan kurang dari untuk membangun perulangan. Terutama jika loop itu melakukan I / O.
Di sisi lain, jika Anda menghitung mundur dari nol, dan menggunakan penghitung sebagai indeks array, misalnya, Anda mungkin menemukan kode bekerja melawan arsitektur memori sistem - memori yang dibaca sering menyebabkan cache untuk 'melihat ke depan' beberapa lokasi memori melewati yang sekarang dalam mengantisipasi pembacaan berurutan. Jika Anda bekerja mundur melalui memori, sistem caching mungkin tidak mengantisipasi pembacaan lokasi memori pada alamat memori yang lebih rendah. Dalam hal ini, ada kemungkinan bahwa pengulangan 'mundur' dapat merusak kinerja. Namun, saya mungkin masih mengkodekan loop dengan cara ini (selama kinerja tidak menjadi masalah) karena kebenaran adalah yang terpenting, dan membuat kode cocok dengan model adalah cara yang bagus untuk membantu memastikan kebenaran. Kode yang salah sama tidak optimalnya seperti yang Anda dapatkan.
Jadi saya cenderung melupakan nasihat profesor (tentu saja, bukan pada ujiannya - Anda harus tetap pragmatis sejauh ruang kelas berjalan), kecuali dan sampai kinerja kode benar-benar penting.
sumber
Pada beberapa CPU lama ada / ada instruksi seperti
DJNZ
== "decrement and jump if not zero". Ini memungkinkan loop yang efisien di mana Anda memasukkan nilai hitungan awal ke dalam register dan kemudian Anda dapat secara efektif mengelola loop pengurangan dengan satu instruksi. Kita berbicara tentang ISA tahun 1980-an di sini - guru Anda benar-benar tidak dapat dihubungi jika menurutnya "aturan praktis" ini masih berlaku pada CPU modern.sumber
Bob,
Tidak sampai Anda melakukan optimasi mikro, di mana Anda akan memiliki manual untuk CPU Anda. Selanjutnya, jika Anda melakukan hal semacam itu, Anda mungkin tidak perlu mengajukan pertanyaan ini. :-) Tapi, gurumu jelas tidak berlangganan ide itu ....
Ada 4 hal yang perlu dipertimbangkan dalam contoh loop Anda:
Perbandingan (seperti yang telah ditunjukkan orang lain) relevan dengan arsitektur prosesor tertentu . Ada lebih banyak jenis prosesor daripada yang menjalankan Windows. Secara khusus, mungkin ada instruksi yang menyederhanakan dan mempercepat perbandingan dengan 0.
Dalam beberapa kasus, lebih cepat untuk menyesuaikan ke atas atau ke bawah. Biasanya kompiler yang baik akan mencari tahu dan mengulangi loop jika bisa. Tidak semua kompiler bagus.
Anda mengakses syscall dengan putchar. Itu sangat lambat. Plus, Anda merender ke layar (secara tidak langsung). Itu bahkan lebih lambat. Pikirkan rasio 1000: 1 atau lebih. Dalam situasi ini, badan loop benar-benar dan benar-benar melebihi biaya penyesuaian / perbandingan loop.
Cache dan tata letak memori dapat memiliki efek besar pada kinerja. Dalam situasi ini, itu tidak masalah. Namun, jika Anda mengakses array dan membutuhkan kinerja optimal, sebaiknya Anda menyelidiki bagaimana kompiler dan prosesor Anda meletakkan akses memori dan menyesuaikan perangkat lunak Anda untuk memaksimalkannya. Contoh stok adalah yang diberikan sehubungan dengan perkalian matriks.
sumber
Yang lebih penting dari apakah Anda menambah atau mengurangi penghitung Anda adalah apakah Anda naik memori atau turun memori. Sebagian besar cache dioptimalkan untuk naik memori, bukan memori turun. Karena waktu akses memori adalah hambatan yang dihadapi sebagian besar program saat ini, ini berarti bahwa mengubah program Anda sehingga Anda meningkatkan memori dapat menghasilkan peningkatan kinerja bahkan jika ini mengharuskan membandingkan penghitung Anda dengan nilai yang tidak nol. Dalam beberapa program saya, saya melihat peningkatan kinerja yang signifikan dengan mengubah kode saya untuk naik memori, bukan turun.
Skeptis? Cukup tulis sebuah program untuk waktu loop naik / turun memori. Inilah hasil yang saya dapat:
(di mana "mus" berarti mikrodetik) dari menjalankan program ini:
Keduanya
sum_abs_up
dansum_abs_down
melakukan hal yang sama (jumlah vektor angka) dan diatur waktunya dengan cara yang sama dengan satu-satunya perbedaan adalah yangsum_abs_up
naik memori saatsum_abs_down
turun memori. Saya bahkan melewativec
referensi sehingga kedua fungsi mengakses lokasi memori yang sama. Namun demikian,sum_abs_up
secara konsisten lebih cepat daripadasum_abs_down
. Coba jalankan sendiri (saya kompilasi dengan g ++ -O3).Penting untuk dicatat seberapa ketat pengulangan yang saya lakukan. Jika tubuh loop besar, maka kemungkinan tidak akan masalah apakah iteratornya naik atau turun memori karena waktu yang dibutuhkan untuk mengeksekusi tubuh loop kemungkinan akan mendominasi sepenuhnya. Juga, penting untuk menyebutkan bahwa dengan beberapa loop yang jarang, memori turun terkadang lebih cepat daripada naik itu. Tetapi bahkan dengan loop seperti itu tidak pernah terjadi bahwa naik memori selalu lebih lambat daripada turun (tidak seperti loop bertubuh kecil yang naik memori, yang sebaliknya sering benar; pada kenyataannya, untuk segelintir kecil loop aku ' Sudah waktunya, peningkatan kinerja dengan naik memori adalah 40 +%).
Intinya adalah, sebagai aturan praktis, jika Anda memiliki pilihan, jika tubuh loop kecil, dan jika ada sedikit perbedaan antara loop Anda naik memori, bukan turun, maka Anda harus naik memori.
FYI
vec_original
ada untuk eksperimen, untuk membuatnya mudah untuk berubahsum_abs_up
dansum_abs_down
dengan cara yang membuat mereka berubahvec
sementara tidak membiarkan perubahan ini mempengaruhi waktu di masa depan. Saya sangat merekomendasikan bermain-main dengansum_abs_up
dansum_abs_down
dan waktu hasil.sumber
terlepas dari arahnya selalu gunakan formulir awalan (++ i bukannya i ++)!
atau
Penjelasan: http://www.eskimo.com/~scs/cclass/notes/sx7b.html
Selanjutnya Anda bisa menulis
Tetapi saya berharap kompiler modern dapat melakukan persis optimasi ini.
sumber
Ini adalah pertanyaan yang menarik, tetapi sebagai hal praktis saya tidak berpikir itu penting dan tidak membuat satu loop lebih baik dari yang lain.
Menurut halaman wikipedia ini: Lompatan kedua , "... hari matahari menjadi 1,7 ms lebih lama setiap abad terutama karena gesekan pasang surut." Tetapi jika Anda menghitung hari sampai hari ulang tahun Anda, apakah Anda benar-benar peduli dengan perbedaan kecil waktu ini?
Lebih penting bahwa kode sumbernya mudah dibaca dan dipahami. Kedua loop tersebut adalah contoh bagus mengapa keterbacaan penting - mereka tidak mengulangi jumlah yang sama.
Saya berani bertaruh bahwa kebanyakan programmer membaca (i = 0; i <N; i ++) dan segera mengerti bahwa ini loop N kali. Lingkaran (i = 1; i <= N; i ++), bagi saya, sedikit kurang jelas, dan dengan (i = N; i> 0; i--) Saya harus memikirkannya sejenak . Paling baik jika maksud kode masuk langsung ke otak tanpa perlu berpikir.
sumber
Anehnya, ada perbedaan. Paling tidak, di PHP. Pertimbangkan tolok ukur berikut:
Hasilnya menarik:
Jika seseorang tahu mengapa, alangkah baiknya untuk mengetahui :)
SUNTING : Hasilnya sama bahkan jika Anda mulai menghitung bukan dari 0, tetapi nilai arbitrer lainnya. Jadi mungkin tidak hanya perbandingan dengan nol yang membuat perbedaan?
sumber
Itu bisa lebih cepat.
Pada prosesor NIOS II saya sedang bekerja dengan, tradisional untuk loop
menghasilkan perakitan:
Jika kita menghitung mundur
kami mendapatkan perakitan yang membutuhkan 2 instruksi lebih sedikit.
Jika kita memiliki loop bersarang, di mana loop dalam dieksekusi banyak, kita dapat memiliki perbedaan yang terukur:
Jika loop dalam ditulis seperti di atas, waktu eksekusi adalah: 0,12199999999999999734 detik. Jika loop dalam ditulis dengan cara tradisional, waktu eksekusi adalah: 0,1719999999999999998623 detik. Jadi loop menghitung mundur sekitar 30% lebih cepat.
Tetapi: tes ini dilakukan dengan semua optimasi GCC dimatikan. Jika kita menyalakannya, kompiler sebenarnya lebih pintar daripada optimisasi tangan ini dan bahkan menyimpan nilai dalam register selama seluruh loop dan kita akan mendapatkan perakitan seperti
Dalam contoh khusus ini, kompiler bahkan memperhatikan, variabel a akan selalu menjadi 1 setelah eksekusi loop dan melewatkan semua loop bersama-sama.
Namun saya mengalami bahwa kadang-kadang jika badan loop cukup kompleks, kompiler tidak dapat melakukan optimasi ini, jadi cara teraman untuk selalu mendapatkan eksekusi loop cepat adalah menulis:
Tentu saja ini hanya berfungsi, jika tidak masalah bahwa loop dieksekusi secara terbalik dan seperti yang dikatakan Betamoo, hanya jika Anda menghitung mundur ke nol.
sumber
Apa yang dikatakan guru Anda adalah pernyataan miring tanpa banyak klarifikasi. BUKAN bahwa pengurangan lebih cepat daripada menambah tetapi Anda dapat membuat loop jauh lebih cepat dengan penurunan daripada dengan kenaikan.
Tanpa panjang lebar tentang hal itu, tanpa perlu menggunakan penghitung lingkaran dll - yang penting di bawah ini hanya kecepatan dan jumlah loop (bukan nol).
Inilah cara kebanyakan orang menerapkan loop dengan 10 iterasi:
Untuk 99% kasus, semua itu mungkin diperlukan tetapi bersama dengan PHP, PYTHON, JavaScript ada seluruh dunia perangkat lunak penting (biasanya tertanam, OS, game, dll.) Di mana kutu CPU sangat penting, jadi lihat sebentar pada kode perakitan:
setelah kompilasi (tanpa optimasi) versi kompilasi mungkin terlihat seperti ini (VS2015):
Seluruh loop adalah 8 instruksi (26 byte). Di dalamnya - sebenarnya ada 6 instruksi (17 byte) dengan 2 cabang. Ya ya saya tahu ini bisa dilakukan dengan lebih baik (ini hanya sebuah contoh).
Sekarang pertimbangkan ini sering membangun yang sering Anda temukan ditulis oleh pengembang tertanam:
Itu juga berulang 10 kali (ya saya tahu saya nilainya berbeda dibandingkan dengan yang ditunjukkan untuk loop tetapi kami peduli dengan iterasi yang dihitung di sini). Ini dapat dikompilasi menjadi ini:
5 instruksi (18 byte) dan hanya satu cabang. Sebenarnya ada 4 instruksi di loop (11 byte).
Yang terbaik adalah bahwa beberapa CPU (termasuk x86 / x64 termasuk) memiliki instruksi yang dapat mengurangi register, kemudian membandingkan hasil dengan nol dan melakukan cabang jika hasilnya berbeda dari nol. Hampir semua PC CPU menerapkan instruksi ini. Menggunakannya, loop sebenarnya hanya satu (ya satu) instruksi 2 byte:
Apakah saya harus menjelaskan mana yang lebih cepat?
Sekarang bahkan jika CPU tertentu tidak mengimplementasikan instruksi di atas semua yang diperlukan untuk meniru itu adalah penurunan diikuti oleh lompatan bersyarat jika hasil dari instruksi sebelumnya adalah nol.
Jadi, terlepas dari beberapa kasus yang Anda tunjukkan sebagai komentar mengapa saya salah, dll, saya menekankan. - YA BERMANFAAT UNTUK MELIHAT KE BAWAH KE BAWAH jika Anda tahu bagaimana, mengapa dan kapan.
PS. Ya saya tahu bahwa kompiler bijak (dengan tingkat optimasi yang sesuai) akan menulis ulang untuk loop (dengan counter loop menaik) menjadi do..sementara setara untuk iterasi loop konstan ... (atau membuka gulungannya) ...
sumber
Tidak, itu tidak sepenuhnya benar. Satu situasi di mana itu bisa lebih cepat adalah ketika Anda seharusnya memanggil fungsi untuk memeriksa batas-batas selama setiap iterasi loop.
Tetapi jika kurang jelas melakukannya seperti itu, itu tidak bermanfaat. Dalam bahasa modern, Anda harus menggunakan loop foreach jika memungkinkan. Anda secara spesifik menyebutkan kasus di mana Anda harus menggunakan loop foreach - ketika Anda tidak membutuhkan indeks.
sumber
for(int i=0, siz=myCollection.size(); i<siz; i++)
.Intinya adalah bahwa ketika menghitung mundur Anda tidak perlu memeriksa
i >= 0
secara terpisah untuk melakukan decrementingi
. Mengamati:Baik perbandingan dan pengurangan
i
dapat dilakukan dalam satu ekspresi.Lihat jawaban lain untuk alasan ini bermuara pada lebih sedikit instruksi x86.
Seperti apakah itu membuat perbedaan yang berarti dalam aplikasi Anda, baik saya kira itu tergantung pada berapa banyak loop yang Anda miliki dan seberapa dalam mereka bersarang. Tetapi bagi saya, sama mudahnya melakukannya dengan cara ini, jadi saya tetap melakukannya.
sumber
Sekarang, saya pikir Anda punya cukup banyak kuliah perakitan :) Saya ingin memberi Anda alasan lain untuk pendekatan top-> down.
Alasan untuk pergi dari atas sangat sederhana. Di tubuh loop, Anda mungkin secara tidak sengaja mengubah batas, yang mungkin berakhir dengan perilaku yang salah atau bahkan loop yang tidak berakhir.
Lihatlah sebagian kecil kode Java ini (bahasa tidak masalah saya kira karena alasan ini):
Jadi poin saya adalah Anda harus mempertimbangkan memilih pergi dari atas ke bawah atau memiliki konstanta sebagai batas.
sumber
for (int i=0; i < 999; i++) {
.for(int xa=0; xa<collection.size(); xa++) { collection.add(SomeObject); ... }
Pada tingkat assembler, sebuah loop yang menghitung mundur ke nol pada umumnya sedikit lebih cepat daripada yang menghitung hingga nilai yang diberikan. Jika hasil perhitungan sama dengan nol, sebagian besar prosesor akan menetapkan tanda nol. Jika mengurangi satu membuat perhitungan membungkus melewati nol lalu ini biasanya akan mengubah bendera carry (pada beberapa prosesor itu akan mengaturnya pada orang lain itu akan menghapusnya), sehingga perbandingan dengan nol pada dasarnya datang secara gratis.
Ini bahkan lebih benar ketika jumlah iterasi bukan konstanta tetapi variabel.
Dalam kasus sepele kompiler mungkin dapat mengoptimalkan arah hitungan loop secara otomatis tetapi dalam kasus yang lebih kompleks mungkin bahwa programmer tahu bahwa arah loop tidak relevan dengan perilaku keseluruhan tetapi kompiler tidak dapat membuktikannya.
sumber