Pertanyaan Asli
Mengapa satu loop jauh lebih lambat dari dua loop?
Kesimpulan:
Kasus 1 adalah masalah interpolasi klasik yang kebetulan tidak efisien. Saya juga berpikir bahwa ini adalah salah satu alasan utama mengapa banyak arsitektur dan pengembang mesin akhirnya membangun dan merancang sistem multi-inti dengan kemampuan untuk melakukan aplikasi multi-berulir serta pemrograman paralel.
Melihatnya dari pendekatan semacam ini tanpa melibatkan bagaimana Perangkat Keras, OS, dan Kompiler bekerja bersama untuk melakukan alokasi tumpukan yang melibatkan bekerja dengan RAM, Cache, File Halaman, dll .; matematika yang merupakan dasar dari algoritma ini menunjukkan kepada kita yang mana dari dua ini adalah solusi yang lebih baik.
Kita dapat menggunakan analogi Boss
makhluk Summation
yang akan mewakili For Loop
yang harus bepergian antara pekerja A
& B
.
Kita dapat dengan mudah melihat bahwa Kasus 2 setidaknya setengah lebih cepat jika tidak sedikit lebih dari Kasus 1 karena perbedaan jarak yang diperlukan untuk melakukan perjalanan dan waktu yang diambil antara pekerja. Matematika ini berbaris hampir secara virtual dan sempurna dengan BenchMark Times serta jumlah perbedaan dalam Instruksi Majelis.
Sekarang saya akan mulai menjelaskan bagaimana semua ini bekerja di bawah ini.
Menilai Masalahnya
Kode OP:
const int n=100000;
for(int j=0;j<n;j++){
a1[j] += b1[j];
c1[j] += d1[j];
}
Dan
for(int j=0;j<n;j++){
a1[j] += b1[j];
}
for(int j=0;j<n;j++){
c1[j] += d1[j];
}
Pertimbangannya
Mempertimbangkan pertanyaan awal OP tentang 2 varian for for loop dan pertanyaannya yang diubah terhadap perilaku cache bersama dengan banyak jawaban luar biasa lainnya dan komentar yang berguna; Saya ingin mencoba dan melakukan sesuatu yang berbeda di sini dengan mengambil pendekatan berbeda tentang situasi dan masalah ini.
Pendekatan
Mempertimbangkan dua loop dan semua diskusi tentang cache dan pengarsipan halaman saya ingin mengambil pendekatan lain untuk melihat ini dari perspektif yang berbeda. Salah satu yang tidak melibatkan cache dan file halaman atau eksekusi untuk mengalokasikan memori, pada kenyataannya, pendekatan ini bahkan tidak menyangkut perangkat keras atau perangkat lunak yang sebenarnya sama sekali.
Perspektif
Setelah melihat kode untuk sementara waktu menjadi sangat jelas apa masalahnya dan apa yang menghasilkannya. Mari kita memecah ini menjadi masalah algoritmik dan melihatnya dari perspektif menggunakan notasi matematika kemudian menerapkan analogi untuk masalah matematika dan juga algoritma.
Apa Yang Kita Ketahui
Kita tahu bahwa loop ini akan berjalan 100.000 kali. Kita juga tahu bahwa a1
, b1
, c1
&d1
adalah pointer pada arsitektur 64-bit. Dalam C ++ pada mesin 32-bit, semua pointer berukuran 4 byte dan pada mesin 64-bit, semuanya berukuran 8 byte karena pointer memiliki panjang yang tetap.
Kami tahu bahwa kami memiliki 32 byte untuk dialokasikan dalam kedua kasus. Satu-satunya perbedaan adalah kita mengalokasikan 32 byte atau 2 set 2-8bytes pada setiap iterasi di mana kasus ke-2 kita mengalokasikan 16 byte untuk setiap iterasi untuk kedua loop independen.
Kedua loop masih sama 32 byte dalam alokasi total. Dengan informasi ini, mari kita lanjutkan dan tunjukkan matematika umum, algoritma, dan analogi dari konsep-konsep ini.
Kami tahu berapa kali set atau kelompok operasi yang sama yang harus dilakukan dalam kedua kasus. Kami tahu jumlah memori yang perlu dialokasikan dalam kedua kasus. Kita dapat menilai bahwa beban kerja keseluruhan dari alokasi antara kedua kasus akan kira-kira sama.
Yang Tidak Kami Ketahui
Kami tidak tahu berapa lama untuk setiap kasus kecuali jika kami menetapkan penghitung dan menjalankan tes benchmark. Namun, tolok ukur sudah termasuk dari pertanyaan awal dan dari beberapa jawaban dan komentar juga; dan kita dapat melihat perbedaan yang signifikan antara keduanya dan ini adalah alasan keseluruhan untuk proposal ini untuk masalah ini.
Mari selidiki
Sudah jelas bahwa banyak yang telah melakukan ini dengan melihat alokasi heap, tes benchmark, melihat RAM, Cache, dan File Halaman. Melihat titik data spesifik dan indeks iterasi spesifik juga dimasukkan dan berbagai percakapan tentang masalah khusus ini membuat banyak orang mulai mempertanyakan hal-hal terkait lainnya tentang hal itu. Bagaimana kita mulai melihat masalah ini dengan menggunakan algoritma matematika dan menerapkan analogi untuk itu? Kami memulai dengan membuat beberapa pernyataan! Lalu kami membangun algoritma kami dari sana.
Pernyataan Kami:
- Kita akan membiarkan loop dan iterasi-nya menjadi Penjumlahan yang dimulai pada 1 dan berakhir pada 100000 alih-alih dimulai dengan 0 seperti pada loop karena kita tidak perlu khawatir tentang skema pengindeksan pengalamatan memori 0 karena kita hanya tertarik pada algoritma itu sendiri.
- Dalam kedua kasus kami memiliki 4 fungsi untuk bekerja dengan dan 2 panggilan fungsi dengan 2 operasi yang dilakukan pada setiap panggilan fungsi. Kami akan mengatur ini sebagai fungsi dan panggilan ke fungsi sebagai berikut:
F1()
, F2()
, f(a)
, f(b)
, f(c)
dan f(d)
.
Algoritma:
Kasus 1: - Hanya satu penjumlahan tetapi dua panggilan fungsi independen.
Sum n=1 : [1,100000] = F1(), F2();
F1() = { f(a) = f(a) + f(b); }
F2() = { f(c) = f(c) + f(d); }
Kasus 2: - Dua penjumlahan tetapi masing-masing memiliki panggilan fungsi tersendiri.
Sum1 n=1 : [1,100000] = F1();
F1() = { f(a) = f(a) + f(b); }
Sum2 n=1 : [1,100000] = F1();
F1() = { f(c) = f(c) + f(d); }
Jika Anda perhatikan F2()
hanya ada di Sum
dari Case1
mana F1()
terkandung Sum
dari Case1
dan di keduanya Sum1
dan Sum2
dari Case2
. Ini akan menjadi bukti nanti ketika kita mulai menyimpulkan bahwa ada optimasi yang terjadi dalam algoritma kedua.
Iterasi melalui Sum
panggilan kasus pertama f(a)
yang akan menambah dirinya sendiri f(b)
lalu panggilan f(c)
yang akan melakukan hal yang sama tetapi menambah f(d)
sendiri untuk setiap 100000
iterasi. Dalam kasus kedua, kita memiliki Sum1
dan Sum2
keduanya bertindak sama seolah-olah mereka memiliki fungsi yang sama dipanggil dua kali berturut-turut.
Dalam hal ini kita dapat memperlakukan Sum1
dan Sum2
sama seperti biasa di Sum
mana Sum
dalam kasus ini terlihat seperti ini: Sum n=1 : [1,100000] { f(a) = f(a) + f(b); }
dan sekarang ini terlihat seperti optimasi di mana kita dapat menganggapnya sebagai fungsi yang sama.
Ringkasan dengan Analogi
Dengan apa yang telah kita lihat dalam kasus kedua hampir tampak seolah-olah ada optimasi karena keduanya untuk loop memiliki tanda tangan yang sama persis, tetapi ini bukan masalah sebenarnya. Masalahnya bukan pekerjaan yang sedang dilakukan oleh f(a)
, f(b)
, f(c)
, dan f(d)
. Dalam kedua kasus dan perbandingan antara keduanya, itu adalah perbedaan dalam jarak yang dijumlahkan oleh Penjumlahan dalam setiap kasus yang memberi Anda perbedaan dalam waktu eksekusi.
Pikirkan For Loops
sebagai Summations
yang melakukan iterasi sebagai Boss
yang memberi perintah kepada dua orang A
& B
dan bahwa pekerjaan mereka adalah daging C
& D
masing-masing dan untuk mengambil beberapa paket dari mereka dan mengembalikannya. Dalam analogi ini, perulangan dan penjumlahan for loop atau penjumlahan kondisi sendiri sebenarnya tidak mewakili Boss
. Apa yang sebenarnya mewakili Boss
bukan dari algoritma matematika aktual secara langsung tetapi dari konsep aktual Scope
dan Code Block
dalam rutin atau subrutin, metode, fungsi, unit terjemahan, dll. Algoritma pertama memiliki 1 ruang lingkup di mana algoritma 2 memiliki 2 cakupan berturut-turut.
Dalam kasus pertama pada setiap slip panggilan, Boss
pergi ke A
dan memberikan pesanan dan A
pergi untuk mengambil B's
paket kemudian Boss
pergi ke C
dan memberikan perintah untuk melakukan hal yang sama dan menerima paket dari D
pada setiap iterasi.
Dalam kasus kedua, Boss
pekerjaan langsung dengan A
pergi dan mengambil B's
paket sampai semua paket diterima. Kemudian Boss
bekerja dengan C
melakukan hal yang sama untuk mendapatkan semua D's
paket.
Karena kita bekerja dengan pointer 8-byte dan berurusan dengan alokasi heap mari kita pertimbangkan masalah berikut. Katakanlah Boss
100 kaki dari A
dan A
500 kaki dari C
. Kita tidak perlu khawatir tentang seberapa jauh dari Boss
awalnya C
karena urutan eksekusi. Dalam kedua kasus, Boss
awalnya perjalanan dari A
pertama lalu ke B
. Analogi ini bukan untuk mengatakan bahwa jarak ini tepat; itu hanya skenario kasus uji yang berguna untuk menunjukkan cara kerja algoritma.
Dalam banyak kasus ketika melakukan alokasi tumpukan dan bekerja dengan cache dan file halaman, jarak antara lokasi alamat ini mungkin tidak banyak berbeda atau mereka dapat bervariasi secara signifikan tergantung pada sifat tipe data dan ukuran array.
Kasus Uji:
Kasus Pertama: Pada iterasi pertamaBoss
harus awalnya berjalan 100 kaki untuk memberikan slip pesanan keA
danA
pergi dan melakukan hal-nya, tetapi kemudianBoss
harus melakukan perjalanan 500 kaki untukC
memberinya slip pesanannya. Kemudian pada iterasi berikutnya dan setiap iterasi lainnya setelahBoss
harus bolak-balik 500 kaki antara keduanya.
Kasus Kedua: TheBoss
harus melakukan perjalanan 100 kaki pada iterasi pertamaA
, tetapi setelah itu, dia sudah ada di sana dan hanya menunggu untukA
kembali sampai semua slip terisi. KemudianBoss
harus menempuh jarak 500 kaki pada iterasi pertamaC
karenaC
berjarak 500 kaki dariA
. Karena iniBoss( Summation, For Loop )
dipanggil tepat setelah bekerja denganA
dia maka hanya menunggu di sana seperti yang dia lakukan denganA
sampai semuaC's
slip pesanan selesai.
Perbedaan Jarak Yang Dijalani
const n = 100000
distTraveledOfFirst = (100 + 500) + ((n-1)*(500 + 500);
// Simplify
distTraveledOfFirst = 600 + (99999*100);
distTraveledOfFirst = 600 + 9999900;
distTraveledOfFirst = 10000500;
// Distance Traveled On First Algorithm = 10,000,500ft
distTraveledOfSecond = 100 + 500 = 600;
// Distance Traveled On Second Algorithm = 600ft;
Perbandingan Nilai Sewenang-wenang
Kita dapat dengan mudah melihat bahwa 600 jauh lebih sedikit dari 10 juta. Sekarang, ini tidak tepat, karena kita tidak tahu perbedaan sebenarnya dalam jarak antara alamat RAM atau dari mana Cache atau File Halaman setiap panggilan pada setiap iterasi akan disebabkan oleh banyak variabel tak terlihat lainnya. Ini hanya penilaian situasi yang harus diperhatikan dan dilihat dari skenario terburuk.
Dari angka-angka ini hampir akan tampak seolah-olah Algoritma One harus lebih 99%
lambat dari Algoritma Dua; Namun, ini hanya Boss's
sebagian atau tanggung jawab algoritma dan tidak memperhitungkan pekerja yang sebenarnya A
, B
, C
, & D
dan apa yang harus mereka lakukan pada setiap iterasi dari Loop. Jadi pekerjaan bos hanya menyumbang sekitar 15 - 40% dari total pekerjaan yang dilakukan. Sebagian besar pekerjaan yang dilakukan melalui pekerja memiliki dampak yang sedikit lebih besar untuk menjaga rasio perbedaan kecepatan menjadi sekitar 50-70%
Pengamatan: - Perbedaan antara kedua algoritma
Dalam situasi ini, itu adalah struktur dari proses pekerjaan yang dilakukan. Ini menunjukkan bahwa Kasus 2 lebih efisien dari optimasi parsial memiliki pernyataan fungsi dan definisi yang sama di mana hanya variabel yang berbeda berdasarkan nama dan jarak yang ditempuh.
Kami juga melihat bahwa jarak total yang ditempuh dalam Kasus 1 jauh lebih jauh daripada dalam Kasus 2 dan kami dapat mempertimbangkan jarak yang ditempuh Faktor Waktu kami antara kedua algoritma. Kasus 1 memiliki banyak pekerjaan yang harus dilakukan daripada Kasus 2 .
Ini dapat diamati dari bukti ASM
instruksi yang ditunjukkan dalam kedua kasus. Bersamaan dengan apa yang telah dinyatakan tentang kasus-kasus ini, ini tidak memperhitungkan fakta bahwa dalam Kasus 1 bos harus menunggu keduanya A
& C
untuk kembali sebelum dia dapat kembali A
lagi untuk setiap iterasi. Ini juga tidak memperhitungkan fakta bahwa jika A
atau B
membutuhkan waktu yang sangat lama maka Boss
pekerja dan pekerja lainnya menganggur menunggu untuk dieksekusi.
Dalam Kasus 2 satu-satunya yang menganggur adalah Boss
sampai pekerja kembali. Jadi, ini pun berdampak pada algoritma.
Pertanyaan yang Diubah OPs
EDIT: Pertanyaannya ternyata tidak relevan, karena perilaku sangat tergantung pada ukuran array (n) dan cache CPU. Jadi jika ada minat lebih lanjut, saya ulangi pertanyaannya:
Bisakah Anda memberikan beberapa wawasan yang mendalam tentang detail yang mengarah pada perilaku cache yang berbeda seperti yang diilustrasikan oleh lima wilayah pada grafik berikut?
Mungkin juga menarik untuk menunjukkan perbedaan antara arsitektur CPU / cache, dengan menyediakan grafik yang sama untuk CPU ini.
Mengenai Pertanyaan Ini
Seperti yang telah saya tunjukkan tanpa keraguan, ada masalah mendasar bahkan sebelum Hardware dan Software terlibat.
Sekarang untuk pengelolaan memori dan caching bersama dengan file halaman, dll. Yang semuanya bekerja bersama dalam satu set sistem terintegrasi antara yang berikut:
The Architecture
{Perangkat Keras, Firmware, beberapa Driver Tertanam, Kernel dan Set Instruksi ASM}.
The OS
{Sistem Manajemen File dan Memori, Driver dan Registry}.
The Compiler
{Unit Terjemahan dan Optimalisasi Kode Sumber}.
- Dan bahkan
Source Code
itu sendiri dengan set (s) algoritma yang berbeda.
Kita sudah bisa melihat bahwa ada hambatan yang terjadi dalam algoritma pertama sebelum kita bahkan menerapkannya pada setiap mesin dengan sewenang-wenang setiap Architecture
, OS
dan Programmable Language
dibandingkan dengan algoritma kedua. Sudah ada masalah sebelum melibatkan intrinsik komputer modern.
Hasil Akhir
Namun; bukan untuk mengatakan bahwa pertanyaan-pertanyaan baru ini tidak penting karena mereka sendiri dan mereka memang memainkan peran. Mereka berdampak pada prosedur dan kinerja keseluruhan dan itu terbukti dengan berbagai grafik dan penilaian dari banyak orang yang telah memberikan jawaban dan komentar mereka.
Jika Anda memperhatikan analogi dari Boss
dan dua pekerja A
& B
yang harus pergi dan mengambil paket dari C
& D
masing-masing dan mempertimbangkan notasi matematika dari dua algoritma yang dimaksud; Anda dapat melihat tanpa keterlibatan perangkat keras dan perangkat lunak komputer Case 2
lebih 60%
cepat dari Case 1
.
Ketika Anda melihat grafik dan bagan setelah algoritma ini telah diterapkan pada beberapa kode sumber, dikompilasi, dioptimalkan, dan dieksekusi melalui OS untuk melakukan operasi mereka pada perangkat keras yang diberikan, Anda bahkan dapat melihat sedikit lebih banyak degradasi antara perbedaan. dalam algoritma ini.
Jika Data
himpunannya cukup kecil, mungkin pada awalnya tidak terlalu buruk. Namun, karena Case 1
ini 60 - 70%
lebih lambat daripada yang Case 2
bisa kita lihat pada pertumbuhan fungsi ini dalam hal perbedaan dalam eksekusi waktu:
DeltaTimeDifference approximately = Loop1(time) - Loop2(time)
//where
Loop1(time) = Loop2(time) + (Loop2(time)*[0.6,0.7]) // approximately
// So when we substitute this back into the difference equation we end up with
DeltaTimeDifference approximately = (Loop2(time) + (Loop2(time)*[0.6,0.7])) - Loop2(time)
// And finally we can simplify this to
DeltaTimeDifference approximately = [0.6,0.7]*Loop2(time)
Perkiraan ini adalah perbedaan rata-rata antara kedua loop ini baik secara algoritmik dan operasi mesin yang melibatkan optimasi perangkat lunak dan instruksi mesin.
Ketika kumpulan data tumbuh secara linear, demikian juga perbedaan waktu antara keduanya. Algoritma 1 memiliki lebih banyak pengambilan daripada algoritma 2 yang terbukti ketika Boss
harus melakukan perjalanan bolak-balik jarak maksimum antara A
& C
untuk setiap iterasi setelah iterasi pertama sedangkan Algoritma 2 Boss
harus melakukan perjalanan ke A
sekali dan kemudian setelah dilakukan dengan A
dia harus melakukan perjalanan jarak maksimum hanya satu kali ketika pergi dari A
ke C
.
Mencoba Boss
memfokuskan pada melakukan dua hal yang sama sekaligus dan menyulapnya bolak-balik alih-alih berfokus pada tugas yang sama secara berurutan akan membuatnya marah pada akhir hari karena ia harus bepergian dan bekerja dua kali lebih banyak. Karena itu, jangan kehilangan ruang lingkup situasi dengan membiarkan atasan Anda mengalami hambatan yang diinterpolasi karena pasangan dan anak-anak bos tidak akan menghargainya.
Amandemen: Prinsip Desain Rekayasa Perangkat Lunak
- Perbedaan antara Local Stack
dan Heap Allocated
perhitungan dalam iteratif untuk loop dan perbedaan antara penggunaannya, efisiensi, dan efektivitasnya -
Algoritma matematika yang saya usulkan di atas terutama berlaku untuk loop yang melakukan operasi pada data yang dialokasikan pada heap.
- Operasi Stack Berturut-turut:
- Jika loop melakukan operasi pada data secara lokal dalam satu blok kode atau cakupan yang ada di dalam stack frame itu masih akan berlaku, tetapi lokasi memori lebih dekat di mana mereka biasanya berurutan dan perbedaan jarak yang ditempuh atau waktu eksekusi hampir dapat diabaikan. Karena tidak ada alokasi yang dilakukan dalam heap, memori tidak tersebar, dan memori tidak diambil melalui ram. Memori biasanya berurutan dan relatif terhadap bingkai tumpukan dan penunjuk tumpukan.
- Ketika operasi berturut-turut dilakukan pada stack, Prosesor modern akan menembolok nilai berulang dan alamat menjaga nilai-nilai ini dalam register cache lokal. Waktu operasi atau instruksi di sini sesuai urutan nano-detik.
- Operasi Alokasi Tumpukan Berturut-turut:
- Ketika Anda mulai menerapkan alokasi tumpukan dan prosesor harus mengambil alamat memori pada panggilan berurutan, tergantung pada arsitektur CPU, Pengendali Bus, dan modul Ram waktu operasi atau eksekusi dapat sesuai urutan mikro untuk milidetik. Dibandingkan dengan operasi stack dalam cache, ini cukup lambat.
- CPU harus mengambil alamat memori dari Ram dan biasanya apa pun di bus sistem lambat dibandingkan dengan jalur data internal atau bus data dalam CPU itu sendiri.
Jadi ketika Anda bekerja dengan data yang perlu di tumpukan dan Anda melintasi mereka dalam loop, itu lebih efisien untuk menjaga setiap set data dan algoritma yang sesuai dalam satu loop sendiri. Anda akan mendapatkan optimasi yang lebih baik dibandingkan dengan mencoba memfaktorkan loop berturut-turut dengan menempatkan beberapa operasi set data yang berbeda yang ada di tumpukan menjadi satu loop.
Tidak apa-apa untuk melakukan ini dengan data yang ada di stack karena mereka sering di-cache, tetapi tidak untuk data yang harus memiliki alamat memorinya ditanyai setiap iterasi.
Di sinilah Rekayasa Perangkat Lunak dan Desain Arsitektur Perangkat Lunak berperan. Ini adalah kemampuan untuk mengetahui bagaimana mengatur data Anda, mengetahui kapan harus menyimpan data Anda, mengetahui kapan harus mengalokasikan data Anda di heap, mengetahui bagaimana merancang dan mengimplementasikan algoritma Anda, dan mengetahui kapan dan di mana memanggilnya.
Anda mungkin memiliki algoritma yang sama yang berkaitan dengan kumpulan data yang sama, tetapi Anda mungkin ingin satu desain implementasi untuk varian stack-nya dan yang lain untuk varian heap-dialokasikan hanya karena masalah di atas yang dilihat dari O(n)
kompleksitas algoritma ketika bekerja dengan heap.
Dari apa yang saya perhatikan selama bertahun-tahun banyak orang tidak mempertimbangkan fakta ini. Mereka akan cenderung merancang satu algoritma yang bekerja pada set data tertentu dan mereka akan menggunakannya terlepas dari set data yang di-cache secara lokal di stack atau jika dialokasikan pada heap.
Jika Anda menginginkan pengoptimalan yang benar, ya itu mungkin tampak seperti duplikasi kode, tetapi untuk menggeneralisasi akan lebih efisien untuk memiliki dua varian dari algoritma yang sama. Satu untuk operasi stack, dan lainnya untuk operasi heap yang dilakukan dalam loop berulang!
Berikut adalah contoh pseudo: Dua struct sederhana, satu algoritma.
struct A {
int data;
A() : data{0}{}
A(int a) : data{a}{}
};
struct B {
int data;
B() : data{0}{}
A(int b) : data{b}{}
}
template<typename T>
void Foo( T& t ) {
// do something with t
}
// some looping operation: first stack then heap.
// stack data:
A dataSetA[10] = {};
B dataSetB[10] = {};
// For stack operations this is okay and efficient
for (int i = 0; i < 10; i++ ) {
Foo(dataSetA[i]);
Foo(dataSetB[i]);
}
// If the above two were on the heap then performing
// the same algorithm to both within the same loop
// will create that bottleneck
A* dataSetA = new [] A();
B* dataSetB = new [] B();
for ( int i = 0; i < 10; i++ ) {
Foo(dataSetA[i]); // dataSetA is on the heap here
Foo(dataSetB[i]); // dataSetB is on the heap here
} // this will be inefficient.
// To improve the efficiency above, put them into separate loops...
for (int i = 0; i < 10; i++ ) {
Foo(dataSetA[i]);
}
for (int i = 0; i < 10; i++ ) {
Foo(dataSetB[i]);
}
// This will be much more efficient than above.
// The code isn't perfect syntax, it's only psuedo code
// to illustrate a point.
Inilah yang saya maksudkan dengan memiliki implementasi terpisah untuk varian stack versus varian heap. Algoritme itu sendiri tidak terlalu penting, itu adalah struktur pengulangan yang akan Anda gunakan.