Kapan biaya panggilan fungsi masih penting dalam kompiler modern?

95

Saya adalah orang yang religius dan berusaha untuk tidak melakukan dosa. Itulah sebabnya saya cenderung menulis fungsi-fungsi kecil ( lebih kecil dari itu , untuk menulis ulang Robert C. Martin) untuk mematuhi beberapa perintah yang diperintahkan oleh Alkitab Kode Bersih . Tetapi ketika memeriksa beberapa hal, saya mendarat di pos ini , di bawah ini saya membaca komentar ini:

Ingat bahwa biaya pemanggilan metode bisa signifikan, tergantung pada bahasanya. Hampir selalu ada tradeoff antara menulis kode yang dapat dibaca dan menulis kode pemain.

Dalam kondisi apa pernyataan yang dikutip ini masih berlaku saat ini mengingat industri kaya dari kompiler modern yang berprestasi?

Itu satu-satunya pertanyaan saya. Dan ini bukan tentang apakah saya harus menulis fungsi yang panjang atau kecil. Saya hanya menyoroti bahwa umpan balik Anda mungkin - atau tidak - berkontribusi untuk mengubah sikap saya dan membuat saya tidak dapat menahan godaan penghujat .

Billal Begueradj
sumber
11
Tulis kode yang dapat dibaca dan dipelihara. Hanya ketika Anda menghadapi masalah dengan stack overflow, Anda dapat memikirkan kembali pendekatan Anda
Fabio
33
Jawaban umum di sini tidak mungkin. Ada terlalu banyak kompiler yang berbeda, mengimplementasikan terlalu banyak spesifikasi bahasa yang berbeda. Dan kemudian ada bahasa yang dikompilasi JIT, bahasa yang ditafsirkan secara dinamis, dan sebagainya. Cukuplah untuk mengatakan, jika Anda mengkompilasi kode C atau C ++ asli dengan kompiler modern, Anda tidak perlu khawatir tentang biaya pemanggilan fungsi. Pengoptimal akan menampilkannya setiap kali sesuai. Sebagai penggemar optimisasi mikro, saya jarang melihat kompiler membuat keputusan inlining yang tidak disetujui oleh saya atau tolok ukur saya.
Cody Gray
6
Berbicara dari pengalaman pribadi, saya menulis kode dalam bahasa berpemilik yang cukup modern dalam hal kemampuan, tetapi pemanggilan fungsi sangat mahal, sampai pada titik di mana bahkan tipikal untuk loop harus dioptimalkan untuk kecepatan: for(Integer index = 0, size = someList.size(); index < size; index++)alih-alih secara sederhana for(Integer index = 0; index < someList.size(); index++). Hanya karena kompiler Anda dibuat dalam beberapa tahun terakhir tidak berarti Anda dapat melepaskan profil.
phyrfox
5
@ phyrfox yang masuk akal, mendapatkan nilai someList.size () di luar loop daripada memanggilnya setiap kali melalui loop. Itu terutama benar jika ada kemungkinan masalah sinkronisasi di mana pembaca dan penulis mungkin mencoba untuk berbenturan selama iterasi, dalam hal ini Anda juga ingin melindungi daftar terhadap perubahan apa pun selama iterasi.
Craig
8
Berhati-hatilah untuk mengambil fungsi kecil terlalu jauh, ini dapat mengaburkan kode sama efisiennya dengan mega-fungsi monolitik. Jika Anda tidak mempercayai saya, periksa beberapa pemenang ioccc.org : Beberapa kode semuanya menjadi satu main(), yang lain membagi semuanya menjadi sekitar 50 fungsi kecil, dan semuanya sama sekali tidak dapat dibaca. Kuncinya adalah, seperti biasa, untuk mencapai keseimbangan yang baik .
cmaster

Jawaban:

148

Itu tergantung pada domain Anda.

Jika Anda menulis kode untuk mikrokontroler berdaya rendah, maka biaya panggilan metode mungkin signifikan. Tetapi jika Anda membuat situs web atau aplikasi normal, maka biaya panggilan metode akan diabaikan dibandingkan dengan sisa kode. Dalam hal ini, akan selalu lebih baik berfokus pada algoritma dan struktur data yang tepat daripada optimasi mikro seperti pemanggilan metode.

Dan ada juga pertanyaan tentang kompiler yang menjelaskan metode untuk Anda. Sebagian besar kompiler cukup cerdas untuk menjalankan fungsi sebisa mungkin.

Dan terakhir, ada aturan kinerja emas: SELALU PROFIL PERTAMA. Jangan menulis kode "dioptimalkan" berdasarkan asumsi. Jika Anda tidak terbiasa, tulis kedua kasing dan lihat mana yang lebih baik.

Euforia
sumber
13
Dan misalnya kompiler HotSpot melakukan Inlining Spekulatif , yang dalam beberapa hal inlining bahkan ketika itu tidak mungkin.
Jörg W Mittag
49
Bahkan, dalam aplikasi web, seluruh kode mungkin tidak signifikan dalam kaitannya dengan akses DB dan lalu lintas jaringan ...
AnoE
72
Aku benar-benar ke dalam tertanam dan daya sangat rendah dengan kompiler yang sangat tua yang nyaris tidak tahu apa artinya optimasi, dan percayalah meskipun fungsi panggilan penting itu tidak pernah menjadi tempat pertama untuk mencari optimasi. Bahkan dalam domain niche ini kualitas kode lebih dulu dalam hal ini.
Tim
2
@Mehrdad Bahkan dalam kasus ini saya akan terkejut jika tidak ada yang lebih relevan untuk dioptimalkan dalam kode. Ketika membuat profil kode saya melihat hal-hal yang jauh lebih berat daripada panggilan fungsi, dan di situlah relevan untuk mencari optimasi. Beberapa pengembang menjadi gila karena satu atau dua LOC yang tidak dioptimalkan tetapi ketika Anda membuat profil SW Anda menyadari bahwa desain lebih penting daripada ini, setidaknya untuk bagian terbesar dari kode. Ketika Anda menemukan hambatan, Anda dapat mencoba mengoptimalkannya, dan itu akan memiliki dampak yang jauh lebih besar daripada optimasi sewenang-wenang tingkat rendah seperti menulis fungsi-fungsi besar untuk menghindari overhead panggilan.
Tim
8
Jawaban yang bagus! Poin terakhir Anda harus menjadi yang pertama: Selalu profil sebelum memutuskan di mana harus mengoptimalkan .
CJ Dennis
56

Fungsi panggilan overhead tergantung sepenuhnya pada bahasa, dan pada tingkat apa Anda mengoptimalkan.

Pada tingkat yang sangat rendah, pemanggilan fungsi dan bahkan pemanggilan metode virtual mungkin lebih mahal jika menyebabkan misprediksi cabang atau kesalahan cache CPU. Jika Anda telah menulis assembler , Anda juga akan tahu bahwa Anda memerlukan beberapa instruksi tambahan untuk menyimpan dan mengembalikan register di sekitar panggilan. Tidak benar bahwa kompiler "cukup pintar" akan dapat menyejajarkan fungsi yang benar untuk menghindari overhead ini, karena kompiler dibatasi oleh semantik bahasa (terutama di sekitar fitur seperti pengiriman metode antarmuka atau perpustakaan yang dimuat secara dinamis).

Pada level yang tinggi, bahasa seperti Perl, Python, Ruby melakukan banyak pembukuan per panggilan fungsi, membuatnya menjadi mahal. Ini diperburuk oleh meta-pemrograman. Saya pernah mempercepat software Python 3x hanya dengan mengangkat fungsi memanggil dari loop yang sangat panas. Dalam kode kritis-kinerja, fungsi pembantu inlining dapat memiliki efek yang nyata.

Tetapi sebagian besar perangkat lunak tidak terlalu kritis terhadap kinerja sehingga Anda dapat melihat fungsi panggilan overhead. Bagaimanapun, menulis bersih, kode sederhana terbayar:

  • Jika kode Anda tidak kritis terhadap kinerja, ini membuat pemeliharaan lebih mudah. Bahkan dalam perangkat lunak yang sangat kritis terhadap kinerja, sebagian besar kode tidak akan menjadi “hot spot”.

  • Jika kode Anda kritis terhadap kinerja, kode sederhana membuatnya lebih mudah untuk memahami kode dan melihat peluang untuk optimisasi. Kemenangan terbesar biasanya tidak datang dari optimasi mikro seperti fungsi inlining, tetapi dari peningkatan algoritmik. Atau diutarakan secara berbeda: jangan melakukan hal yang sama lebih cepat. Temukan cara untuk melakukan lebih sedikit.

Perhatikan bahwa "kode sederhana" tidak berarti "diperhitungkan dalam ribuan fungsi kecil". Setiap fungsi juga memperkenalkan sedikit overhead kognitif - lebih sulit untuk berpikir tentang kode yang lebih abstrak. Pada titik tertentu, fungsi-fungsi kecil ini mungkin melakukan sedikit sehingga tidak menggunakannya akan menyederhanakan kode Anda.

amon
sumber
16
DBA yang sangat cerdas pernah mengatakan kepada saya, "Normalisasikan sampai sakit, lalu denormalkan sampai tidak." Menurut saya itu bisa diulang menjadi "Ekstrak metode sampai menyakitkan, lalu sebaris sampai tidak."
RubberDuck
1
Selain overhead kognitif, ada overhead simbolik dalam informasi debugger, dan biasanya overhead dalam binari akhir tidak dapat dihindari.
Frank Hileman
Mengenai kompiler pintar - mereka BISA melakukannya, hanya saja tidak selalu. Sebagai contoh, jvm dapat menguraikan hal-hal berdasarkan profil runtime dengan jebakan yang sangat murah / bebas untuk lintasan tidak biasa atau fungsi polimorfik sebaris yang hanya ada satu implementasi metode / antarmuka yang diberikan dan kemudian mengoptimasi panggilan tersebut ke polimorfik dengan benar ketika subkelas baru dimuat secara dinamis pada runtime. Tapi ya, ada banyak bahasa di mana hal-hal seperti itu tidak mungkin dan banyak kasus bahkan dalam jvm, ketika itu tidak hemat biaya atau mungkin dalam kasus umum.
Artur Biesiadowski
19

Hampir semua pepatah tentang kode tuning untuk kinerja adalah kasus khusus hukum Amdahl . Pernyataan pendek, lucu dari hukum Amdahl adalah

Jika satu bagian dari program Anda membutuhkan 5% dari runtime, dan Anda mengoptimalkan bagian itu sehingga sekarang membutuhkan nol persen dari runtime, program secara keseluruhan hanya akan 5% lebih cepat.

(Mengoptimalkan semuanya hingga nol persen dari runtime benar-benar mungkin: ketika Anda duduk untuk mengoptimalkan program yang besar dan rumit, Anda kemungkinan besar akan menemukan bahwa ia menghabiskan setidaknya beberapa runtime untuk hal- hal yang tidak perlu dilakukan sama sekali. .)

Inilah sebabnya mengapa orang biasanya mengatakan tidak perlu khawatir tentang biaya panggilan fungsi: tidak peduli seberapa mahal mereka, biasanya program secara keseluruhan hanya menghabiskan sebagian kecil dari runtime ongkos panggilan, jadi mempercepat mereka tidak banyak membantu .

Tapi, jika ada trik yang bisa Anda tarik yang membuat semua fungsi memanggil lebih cepat, trik itu mungkin sepadan. Pengembang kompiler menghabiskan banyak waktu untuk mengoptimalkan fungsi "prolog" dan "epilog", karena itu menguntungkan semua program yang dikompilasi dengan kompiler itu, meskipun hanya sedikit untuk masing-masingnya.

Dan, jika Anda memiliki alasan untuk percaya bahwa program ini menghabiskan banyak runtime yang hanya membuat panggilan fungsi, maka Anda harus mulai berpikir tentang apakah beberapa dari mereka fungsi panggilan yang tidak perlu. Berikut adalah beberapa aturan praktis untuk mengetahui kapan Anda harus melakukan ini:

  • Jika runtime per-pemanggilan suatu fungsi kurang dari satu milidetik, tetapi fungsi itu disebut ratusan ribu kali, mungkin harus digarisbawahi.

  • Jika profil program menunjukkan ribuan fungsi, dan tidak satu pun dari mereka mengambil lebih dari 0,1% atau lebih runtime, maka fungsi-panggilan overhead mungkin signifikan secara agregat.

  • Jika Anda memiliki " kode lasagna ," di mana ada banyak lapisan abstraksi yang hampir tidak bekerja di luar pengiriman ke lapisan berikutnya, dan semua lapisan ini diimplementasikan dengan panggilan metode virtual, maka ada kemungkinan besar CPU membuang-buang banyak waktu di warung pipa cabang tidak langsung. Sayangnya, satu-satunya obat untuk ini adalah menyingkirkan beberapa lapisan, yang seringkali sangat sulit.

zwol
sumber
7
Waspadalah terhadap hal-hal mahal yang dilakukan jauh di loop bersarang. Saya telah mengoptimalkan satu fungsi dan mendapatkan kode yang berjalan 10x lebih cepat. Itu setelah profiler menunjukkan pelakunya. (Itu dipanggil berulang-ulang, dalam loop dari O (n ^ 3) ke kecil n O (n ^ 6).)
Loren Pechtel
"Sayangnya, satu-satunya obat untuk ini adalah menyingkirkan beberapa lapisan, yang seringkali sangat sulit." - ini sangat tergantung pada kompiler bahasa Anda dan / atau teknologi mesin virtual. Jika Anda dapat memodifikasi kode untuk mempermudah kompiler untuk inline (mis. Dengan menggunakan finalkelas dan metode yang berlaku di Jawa, atau non- virtualmetode dalam C # atau C ++) maka tipuan dapat dihilangkan oleh kompiler / runtime dan Anda ' Saya akan melihat keuntungan tanpa restrukturisasi besar-besaran. Seperti yang ditunjukkan oleh @JorgWMittag di atas, JVM bahkan dapat sejajar dalam kasus di mana optimasi tidak dapat dilakukan ...
Jules
... valid, jadi mungkin saja ia melakukannya dalam kode Anda meskipun ada layering.
Jules
@ Jules Meskipun memang benar bahwa kompiler JIT dapat melakukan optimasi spekulatif, itu tidak berarti bahwa optimasi tersebut diterapkan secara seragam. Khusus mengenai Java, pengalaman saya adalah bahwa budaya pengembang menyukai lapisan yang bertumpuk di atas lapisan yang mengarah ke tumpukan panggilan yang sangat dalam. Secara anekdot, yang berkontribusi pada rasa lamban, rasa kembung dari banyak aplikasi Java. Arsitektur yang sangat berlapis seperti itu bekerja terhadap runtime JIT, terlepas dari apakah layer-layer tersebut secara teknis tidak dapat digariskan. JIT bukanlah peluru ajaib yang secara otomatis dapat memperbaiki masalah struktural.
amon
@amon Pengalaman saya dengan "kode lasagna" berasal dari aplikasi C ++ yang sangat besar dengan banyak kode yang berasal dari tahun 1990-an, ketika hierarki objek yang sangat bersarang dan COM menjadi mode. Kompiler C ++ pergi ke upaya yang cukup heroik untuk menghancurkan hukuman abstraksi dalam program-program seperti ini, dan Anda mungkin masih melihat mereka menghabiskan sebagian besar runtime jam dinding di warung pipa cabang-cabang tidak langsung (dan potongan signifikan lainnya pada kesalahan cache I) .
zwol
17

Saya akan menantang kutipan ini:

Hampir selalu ada tradeoff antara menulis kode yang dapat dibaca dan menulis kode pemain.

Ini adalah pernyataan yang sangat menyesatkan, dan sikap yang berpotensi berbahaya. Ada beberapa kasus khusus di mana Anda harus melakukan tradeoff, tetapi secara umum kedua faktor tersebut bersifat independen.

Contoh pengorbanan yang diperlukan adalah ketika Anda memiliki algoritma sederhana versus yang lebih kompleks tetapi lebih banyak performan. Implementasi hashtable jelas lebih kompleks daripada implementasi daftar tertaut, tetapi pencarian akan lebih lambat, jadi Anda mungkin harus berdagang kesederhanaan (yang merupakan faktor dalam keterbacaan) untuk kinerja.

Mengenai overhead panggilan fungsi, mengubah algoritma rekursif menjadi iteratif mungkin memiliki manfaat yang signifikan tergantung pada algoritma dan bahasa. Tapi ini lagi skenario yang sangat spesifik, dan secara umum overhead panggilan fungsi akan diabaikan atau dioptimalkan.

(Beberapa bahasa dinamis seperti Python memang memiliki overhead panggilan metode yang signifikan. Tetapi jika kinerja menjadi masalah, Anda mungkin seharusnya tidak menggunakan Python sejak awal.)

Sebagian besar prinsip untuk kode yang dapat dibaca - pemformatan yang konsisten, nama pengenal yang bermakna, komentar yang sesuai dan bermanfaat dan sebagainya tidak berpengaruh pada kinerja. Dan beberapa - seperti menggunakan enum daripada string - juga memiliki manfaat kinerja.

JacquesB
sumber
5

Fungsi panggilan overhead tidak penting dalam banyak kasus.

Namun, keuntungan yang lebih besar dari kode inlining adalah mengoptimalkan kode baru setelah inlining .

Misalnya, jika Anda memanggil fungsi dengan argumen konstan, pengoptimal sekarang dapat melipat konstan argumen yang sebelumnya tidak dapat digarisbawahi panggilan. Jika argumennya adalah pointer fungsi (atau lambda), pengoptimal sekarang dapat menyejajarkan panggilan ke lambda itu juga.

Ini adalah alasan besar mengapa fungsi virtual dan pointer fungsi tidak menarik karena Anda tidak dapat menyejajarkannya sama sekali kecuali jika pointer fungsi aktual telah dilipat terus-menerus ke situs panggilan.

ratchet freak
sumber
5

Dengan asumsi kinerja memang penting untuk program Anda, dan memang memiliki banyak panggilan, biayanya masih mungkin atau tidak masalah tergantung pada jenis panggilannya.

Jika fungsi yang dipanggil kecil, dan kompiler dapat menyatukannya, maka biayanya akan menjadi nol. Kompiler modern / implementasi bahasa memiliki JIT, tautan-waktu-optimasi dan / atau sistem modul yang dirancang untuk memaksimalkan kemampuan fungsi inline ketika itu bermanfaat.

OTOH, ada biaya yang tidak jelas untuk panggilan fungsi: keberadaan mereka hanya dapat menghambat optimasi kompiler sebelum dan sesudah panggilan.

Jika kompiler tidak dapat memberi alasan tentang apa fungsi yang dipanggil berfungsi (mis. Pengiriman virtual / dinamis atau fungsi dalam perpustakaan dinamis) maka mungkin harus berasumsi dengan pesimis bahwa fungsi tersebut dapat memiliki efek samping — melempar pengecualian, memodifikasi negara global, atau ubah memori yang terlihat melalui pointer. Kompiler mungkin harus menyimpan nilai sementara untuk mendukung memori dan membacanya kembali setelah panggilan. Itu tidak akan dapat memesan ulang instruksi di sekitar panggilan, sehingga mungkin tidak dapat melakukan vektorisasi loop atau hoist perhitungan yang berlebihan keluar dari loop.

Misalnya, jika Anda perlu memanggil fungsi di setiap iterasi loop:

for(int i=0; i < /* gasp! */ strlen(s); i++) x ^= s[i];

Compiler mungkin tahu itu adalah fungsi murni, dan memindahkannya keluar dari loop (dalam kasus yang mengerikan seperti contoh ini bahkan memperbaiki algoritma O (n ^ 2) yang tidak disengaja menjadi O (n)):

for(int i=0, end=strlen(s); i < end; i++) x ^= s[i];

Dan bahkan mungkin menulis ulang loop untuk memproses elemen 4/8/16 sekaligus menggunakan instruksi lebar / SIMD.

Tetapi jika Anda menambahkan panggilan ke beberapa kode buram dalam loop, bahkan jika panggilan tidak melakukan apa-apa dan sangat murah itu sendiri, kompiler harus menganggap yang terburuk - bahwa panggilan akan mengakses variabel global yang menunjuk ke memori yang sama dengan sperubahan isinya (bahkan jika itu constdalam fungsi Anda, itu bisa menjadi non- consttempat lain), membuat optimasi tidak mungkin:

for(int i=0; i < strlen(s); i++) {
    x ^= s[i];
    do_nothing();
}
Kornel
sumber
3

Makalah lama ini mungkin menjawab pertanyaan Anda:

Guy Lewis Steele, Jr .. "Membongkar Mitos 'Prosedur Panggilan Mahal', atau, Implementasi Panggilan Prosedur Dianggap Berbahaya, atau, Lambda: The Ultimate GOTO". MIT AI Lab. AI Lab Memo AIM-443. Oktober 1977.

Abstrak:

Cerita rakyat menyatakan bahwa pernyataan GOTO "murah", sementara panggilan prosedur "mahal". Mitos ini sebagian besar merupakan hasil dari implementasi bahasa yang dirancang dengan buruk. Pertumbuhan historis mitos ini dipertimbangkan. Baik ide-ide teoritis dan implementasi yang ada dibahas yang menyanggah mitos ini. Terlihat bahwa penggunaan prosedur yang tidak terbatas memungkinkan kebebasan penuh gaya. Secara khusus, diagram alur apa pun dapat ditulis sebagai program "terstruktur" tanpa memperkenalkan variabel tambahan. Kesulitan dengan pernyataan GOTO dan panggilan prosedur ditandai sebagai konflik antara konsep pemrograman abstrak dan konstruksi bahasa konkret.

Alex Vong
sumber
12
Saya sangat meragukan makalah yang lama akan menjawab pertanyaan apakah "biaya fungsi panggilan masih penting dalam kompiler modern ".
Cody Grey
6
@CodyGray Saya pikir teknologi kompiler seharusnya sudah maju sejak tahun 1977. Jadi jika panggilan fungsi dapat dibuat murah pada tahun 1977, kita harus dapat melakukannya sekarang. Jadi jawabannya tidak. Tentu saja, ini mengasumsikan Anda menggunakan implementasi bahasa yang layak yang dapat melakukan hal-hal seperti inlining fungsi.
Alex Vong
4
@AlexVong Mengandalkan optimisasi compiler tahun 1977 adalah seperti mengandalkan tren harga komoditas di zaman batu. Segalanya telah berubah terlalu banyak. Misalnya, perkalian digunakan untuk diganti dengan akses memori sebagai operasi yang lebih murah. Saat ini, harganya lebih mahal dengan faktor yang sangat besar. Panggilan metode virtual relatif jauh lebih mahal daripada biasanya (akses memori dan misprediksi cabang), tetapi seringkali mereka dapat dioptimalkan jauh dan panggilan metode virtual bahkan dapat digarisbawahi (Java selalu melakukannya), sehingga biayanya persis nol. Tidak ada yang seperti ini pada tahun 1977.
maaartinus
3
Seperti yang telah ditunjukkan orang lain, bukan hanya perubahan dalam teknologi kompiler yang telah membatalkan penelitian lama. Jika kompiler terus meningkat sementara arsitektur mikro sebagian besar tetap tidak berubah, maka kesimpulan makalah masih akan valid. Tetapi itu tidak terjadi. Jika ada, mikroarsitektur telah berubah lebih dari kompiler. Hal-hal yang dulu cepat sekarang lambat, relatif berbicara.
Cody Grey
2
@AlexVong Untuk lebih tepatnya pada perubahan CPU yang membuat kertas itu usang: Kembali pada tahun 1977, akses memori utama adalah siklus CPU tunggal. Saat ini, bahkan akses sederhana dari cache L1 (!) Memiliki latensi 3 hingga 4 siklus. Sekarang, panggilan fungsi cukup berat dalam akses memori (pembuatan bingkai stack, penyimpanan alamat kembali, penyimpanan register untuk variabel lokal), yang dengan mudah mendorong biaya panggilan fungsi tunggal ke 20 dan lebih banyak siklus. Jika fungsi Anda hanya mengatur ulang argumennya, dan mungkin menambahkan argumen konstan lain untuk diteruskan ke panggilan-melalui, maka itu hampir 100% overhead.
cmaster
3
  • Dalam C ++ waspadalah dalam merancang panggilan fungsi yang menyalin argumen, defaultnya adalah "lewat nilai". Fungsi panggilan overhead karena menyimpan register dan hal-hal lain yang berkaitan dengan stack-frame dapat dikuasai oleh salinan objek yang tidak disengaja (dan berpotensi sangat mahal).

  • Ada optimasi terkait tumpukan-bingkai yang harus Anda selidiki sebelum menyerah pada kode yang sangat diperhitungkan.

  • Sebagian besar waktu ketika saya harus berurusan dengan program lambat saya menemukan membuat perubahan algoritmik menghasilkan peningkatan kecepatan yang jauh lebih besar daripada panggilan fungsi in-lining. Sebagai contoh: insinyur lain membuat parser yang mengisi struktur peta-peta. Sebagai bagian dari itu, ia menghapus indeks cache dari satu peta ke yang terkait secara logis. Itu adalah langkah ketahanan kode yang bagus, namun itu membuat program tidak dapat digunakan karena faktor perlambatan 100 karena melakukan pencarian hash untuk semua akses masa depan dibandingkan dengan menggunakan indeks yang disimpan. Profiling menunjukkan bahwa sebagian besar waktu dihabiskan dalam fungsi hashing.

pengguna2543191
sumber
4
Nasihat pertama agak lama. Sejak C ++ 11, perpindahan telah dimungkinkan. Khususnya, untuk fungsi yang perlu memodifikasi argumen mereka secara internal, mengambil argumen berdasarkan nilai dan mengubahnya di tempat bisa menjadi pilihan yang paling efisien.
MSalters
@ MSalters: Saya pikir Anda salah mengira "khususnya" dengan "selanjutnya" atau sesuatu. Keputusan untuk mengirimkan salinan atau referensi ada sebelum C ++ 11 (saya tahu Anda tahu itu).
phresnel
@pheapnel: Saya pikir saya sudah benar. Kasus khusus yang saya maksudkan adalah kasus di mana Anda membuat sementara di pemanggil, memindahkannya ke argumen, dan kemudian memodifikasinya di callee. Ini tidak mungkin dilakukan sebelum C ++ 11, karena C ++ 03 tidak dapat / tidak akan mengikat referensi non-const untuk sementara ..
MSalters
@ MSalters: Maka saya telah salah mengerti komentar Anda saat pertama kali membacanya. Sepertinya Anda menyiratkan bahwa sebelum C ++ 11, melewati nilai bukanlah sesuatu yang akan dilakukan jika seseorang ingin memodifikasi nilai yang diteruskan.
phresnel
Munculnya 'bergerak' membantu paling signifikan dalam pengembalian benda-benda yang lebih mudah dibangun dalam fungsi daripada di luar dan diteruskan dengan referensi. Sebelum itu mengembalikan objek dari suatu fungsi meminta salinan, seringkali merupakan langkah yang mahal. Itu tidak berurusan dengan argumen fungsi. Saya hati-hati memasukkan kata "mendesain" ke dalam komentar karena orang harus secara eksplisit memberikan izin kepada kompiler untuk 'pindah' ​​ke argumen fungsi (&& sintaks). Saya memiliki kebiasaan 'menghapus' copy constructor untuk mengidentifikasi tempat-tempat di mana hal itu berharga.
user2543191
2

Ya, prediksi cabang yang terlewatkan lebih mahal pada perangkat keras modern daripada beberapa dekade yang lalu, tetapi kompiler telah menjadi jauh lebih pintar dalam mengoptimalkan ini.

Sebagai contoh, pertimbangkan Java. Sekilas, fungsi panggilan overhead harus sangat dominan dalam bahasa ini:

  • fungsi kecil tersebar luas karena konvensi JavaBean
  • fungsi default ke virtual, dan biasanya
  • unit kompilasi adalah kelas; runtime mendukung memuat kelas baru kapan saja, termasuk subclass yang menggantikan metode monomorphic sebelumnya

Ngeri dengan praktik-praktik ini, rata-rata programmer C akan memprediksi bahwa Java harus setidaknya satu urutan besarnya lebih lambat dari C. Dan 20 tahun yang lalu ia akan benar. Namun tolok ukur modern menempatkan kode Java idiomatik dalam beberapa persen dari kode C yang setara. Bagaimana mungkin?

Salah satu alasannya adalah bahwa fungsi inline JVM modern memanggil sebagai hal yang biasa. Itu melakukannya menggunakan spekulasi inlining:

  1. Kode yang baru dimuat dijalankan tanpa optimisasi. Selama tahap ini, untuk setiap situs panggilan, JVM melacak metode mana yang benar-benar dipanggil.
  2. Setelah kode telah diidentifikasi sebagai hotspot kinerja, runtime menggunakan statistik ini untuk mengidentifikasi jalur eksekusi yang paling mungkin, dan menggarisbawahi itu, awalan dengan cabang bersyarat jika optimasi spekulatif tidak berlaku.

Yaitu, kodenya:

int x = point.getX();

akan ditulis ulang menjadi

if (point.class != Point) GOTO interpreter;
x = point.x;

Dan tentu saja runtime cukup pintar untuk naik ke pemeriksaan jenis ini selama titik tidak ditetapkan, atau buang jika jenisnya diketahui kode panggilan.

Singkatnya, jika bahkan Java mengelola metode otomatis inlining, tidak ada alasan yang melekat mengapa kompiler tidak dapat mendukung inlining otomatis, dan setiap alasan untuk melakukannya, karena inlining sangat bermanfaat pada prosesor modern. Karena itu saya hampir tidak bisa membayangkan kompiler arus utama modern yang tidak mengetahui strategi optimasi yang paling mendasar ini, dan akan menganggap kompiler yang mampu melakukan ini kecuali terbukti sebaliknya.

meriton - mogok
sumber
4
"Tidak ada alasan yang melekat mengapa kompiler tidak dapat mendukung inlining otomatis" - ada. Anda telah berbicara tentang kompilasi JIT, yang berarti kode modifikasi sendiri (yang dapat mencegah keamanan karena OS) dan kemampuan untuk melakukan optimasi program penuh yang dipandu profil otomatis. Kompiler AOT untuk bahasa yang memungkinkan penautan dinamis tidak cukup tahu untuk melakukan devirtualize dan inline panggilan apa pun. OTOH: kompiler AOT memiliki waktu untuk mengoptimalkan semua yang dapat dilakukannya, kompiler JIT hanya memiliki waktu untuk fokus pada optimasi murah di hot spot. Dalam kebanyakan kasus, itu membuat JIT sedikit dirugikan.
amon
2
Katakan padaku satu OS yang mencegah menjalankan Google Chrome "karena keamanan" (V8 mengkompilasi JavaScript ke kode asli saat runtime). Juga, ingin menyejajarkan AOT bukan alasan yang melekat (tidak ditentukan oleh bahasa, tetapi arsitektur yang Anda pilih untuk kompiler Anda), dan sementara penghubung dinamis tidak menghambat AOT inlining di seluruh unit kompilasi, itu tidak menghambat inlining dalam kompilasi unit, tempat sebagian besar panggilan berlangsung. Bahkan, inlining yang bermanfaat bisa dibilang lebih mudah dalam bahasa yang menggunakan tautan dinamis kurang berlebihan dari Jawa.
meriton - saat mogok
4
Khususnya, iOS mencegah JIT untuk aplikasi yang tidak diistimewakan. Chrome atau Firefox harus menggunakan tampilan web yang disediakan Apple alih-alih mesin mereka sendiri. Poin bagusnya adalah bahwa AOT vs JIT adalah level implementasi, bukan pilihan level bahasa.
amon
@meriton Windows 10 S dan sistem operasi konsol video game juga cenderung memblokir mesin JIT pihak ketiga.
Damian Yerrick
2

Seperti yang orang lain katakan, Anda harus mengukur kinerja program Anda terlebih dahulu, dan mungkin tidak akan menemukan perbedaan dalam praktiknya.

Namun, dari level konseptual, saya pikir saya akan menjelaskan beberapa hal yang tergabung dalam pertanyaan Anda. Pertama, Anda bertanya:

Apakah biaya panggilan fungsi masih penting dalam kompiler modern?

Perhatikan kata-kata kunci "fungsi" dan "penyusun". Kutipan Anda berbeda secara subtil:

Ingat bahwa biaya pemanggilan metode bisa signifikan, tergantung pada bahasanya.

Ini berbicara tentang metode , dalam arti berorientasi objek.

Sementara "fungsi" dan "metode" sering digunakan secara bergantian, ada perbedaan dalam hal biayanya (yang Anda tanyakan) dan ketika menyangkut kompilasi (yang merupakan konteks yang Anda berikan).

Secara khusus, kita perlu tahu tentang pengiriman statis vs pengiriman dinamis . Saya akan mengabaikan optimisasi untuk saat ini.

Dalam bahasa seperti C, kami biasanya memanggil fungsi dengan pengiriman statis . Sebagai contoh:

int foo(int x) {
  return x + 1;
}

int bar(int y) {
  return foo(y);
}

int main() {
  return bar(42);
}

Ketika kompiler melihat panggilan foo(y), ia tahu fungsi apa yang foomerujuk nama itu, sehingga program keluaran bisa langsung melompat ke foofungsi, yang cukup murah. Itulah arti pengiriman statis .

Alternatifnya adalah pengiriman dinamis , di mana kompiler tidak tahu fungsi mana yang dipanggil. Sebagai contoh, inilah beberapa kode Haskell (karena setara C akan berantakan!):

foo x = x + 1

bar f x = f x

main = print (bar foo 42)

Di sini barfungsinya memanggil argumennya f, yang bisa berupa apa saja. Karenanya kompiler tidak bisa hanya mengkompilasi barke instruksi lompatan cepat, karena ia tidak tahu ke mana harus melompat. Sebagai gantinya, kode yang kita hasilkan untuk bardereference akan fmencari tahu fungsi yang ditunjuknya, lalu beralih ke sana. Itulah arti pengiriman dinamis .

Kedua contoh tersebut adalah untuk fungsi . Anda menyebutkan metode , yang dapat dianggap sebagai gaya fungsi khusus yang dikirim secara dinamis. Sebagai contoh, inilah beberapa Python:

class A:
  def __init__(self, x):
    self.x = x

  def foo(self):
    return self.x + 1

def bar(y):
  return y.foo()

z = A(42)
bar(z)

The y.foo()panggilan menggunakan dispatch dinamis, karena itu mencari nilai dari fooproperti di yobjek, dan memanggil apa pun yang ditemukan; tidak tahu bahwa yakan ada kelas A, atau bahwa Akelas berisi foometode, jadi kita tidak bisa langsung langsung ke sana.

OK, itu ide dasarnya. Perhatikan bahwa pengiriman statis lebih cepat daripada pengiriman dinamis terlepas dari apakah kami mengkompilasi atau menafsirkan; semuanya sama. Dereferencing dikenakan biaya tambahan.

Jadi bagaimana hal ini memengaruhi kompiler modern dan optimal?

Hal pertama yang perlu diperhatikan adalah pengiriman statis dapat dioptimalkan lebih berat: ketika kita tahu ke mana fungsi kita melompat, dapat melakukan hal-hal seperti inlining. Dengan pengiriman dinamis, kami tidak tahu bahwa kami akan melompat sampai waktu berjalan, jadi tidak banyak optimasi yang dapat kami lakukan.

Kedua, dimungkinkan dalam beberapa bahasa untuk menyimpulkan di mana beberapa pengiriman dinamis akan berakhir melompat, dan karenanya mengoptimalkannya menjadi pengiriman statis. Ini memungkinkan kami melakukan optimisasi lain seperti inlining, dll.

Dalam contoh Python di atas, kesimpulan semacam itu sangat tidak ada harapan, karena Python memungkinkan kode lain untuk mengesampingkan kelas dan properti, sehingga sulit untuk menyimpulkan banyak hal yang akan berlaku dalam semua kasus.

Jika bahasa kita memungkinkan kita memaksakan lebih banyak pembatasan, misalnya dengan membatasi ykelas Amenggunakan anotasi, maka kita dapat menggunakan informasi itu untuk menyimpulkan fungsi target. Dalam bahasa dengan subclassing (yang hampir semua bahasa dengan kelas!) Itu sebenarnya tidak cukup, karena ymungkin sebenarnya memiliki kelas (sub) yang berbeda, jadi kita akan membutuhkan informasi tambahan seperti finalanotasi Java untuk mengetahui dengan tepat fungsi mana yang akan dipanggil.

Haskell bukan bahasa OO, tapi kami dapat menyimpulkan nilai foleh inlining bar(yang statis dikirim) ke main, menggantikan foountuk y. Karena target fooin maindiketahui secara statis, panggilan menjadi dikirim secara statis, dan mungkin akan diuraikan dan dioptimalkan sepenuhnya (karena fungsi-fungsi ini kecil, kompiler lebih cenderung untuk menyejajarkannya, meskipun kita tidak dapat mengandalkannya secara umum ).

Karenanya biaya turun ke:

  • Apakah bahasa mengirim panggilan Anda secara statis atau dinamis?
  • Jika yang terakhir, apakah bahasa memungkinkan implementasi menyimpulkan target menggunakan informasi lain (misalnya jenis, kelas, anotasi, sebaris, dll.)?
  • Seberapa agresif pengiriman statis (disimpulkan atau tidak) dioptimalkan?

Jika Anda menggunakan bahasa "sangat dinamis", dengan banyak pengiriman dinamis dan sedikit jaminan yang tersedia untuk kompiler, maka setiap panggilan akan dikenai biaya. Jika Anda menggunakan bahasa "sangat statis", maka kompiler yang matang akan menghasilkan kode yang sangat cepat. Jika Anda berada di antara keduanya, maka itu dapat bergantung pada gaya pengkodean Anda dan seberapa pintar implementasinya.

Warbo
sumber
1
Saya tidak setuju bahwa memanggil penutupan (atau penunjuk fungsi ) - seperti contoh Haskell Anda - adalah pengiriman dinamis. pengiriman dinamis melibatkan beberapa perhitungan (misalnya menggunakan beberapa vtable ) untuk mendapatkan penutupan itu, sehingga lebih mahal daripada panggilan tidak langsung. Kalau tidak, jawaban yang bagus.
Basile Starynkevitch
2

Ingat bahwa biaya pemanggilan metode bisa signifikan, tergantung pada bahasanya. Hampir selalu ada tradeoff antara menulis kode yang dapat dibaca dan menulis kode pemain.

Sayangnya, ini sangat tergantung pada:

  • toolchain kompiler, termasuk JIT jika ada,
  • domain.

Pertama-tama, hukum pertama dari optimasi kinerja adalah profil terlebih dahulu . Ada banyak domain di mana kinerja bagian perangkat lunak tidak relevan dengan kinerja seluruh tumpukan: panggilan basis data, operasi jaringan, operasi OS, ...

Ini berarti bahwa kinerja perangkat lunak sama sekali tidak relevan, bahkan jika itu tidak meningkatkan latensi, mengoptimalkan perangkat lunak dapat menghasilkan penghematan energi dan penghematan perangkat keras (atau penghematan baterai untuk aplikasi seluler), yang dapat menjadi masalah.

Namun, itu biasanya TIDAK bisa eye-balled, dan sering kali peningkatan algoritmik mengalahkan optimasi mikro dengan margin besar.

Jadi, sebelum mengoptimalkan, Anda perlu memahami untuk apa Anda mengoptimalkan ... dan apakah itu layak.


Sekarang, berkenaan dengan kinerja perangkat lunak murni, ini sangat bervariasi antara toolchains.

Ada dua biaya untuk panggilan fungsi:

  • biaya waktu berjalan,
  • biaya waktu kompilasi.

Biaya run time agak jelas; untuk melakukan panggilan fungsi diperlukan sejumlah pekerjaan. Sebagai contoh, menggunakan C pada x86, panggilan fungsi akan membutuhkan (1) menumpahkan register ke stack, (2) mendorong argumen ke register, melakukan panggilan, dan kemudian (3) mengembalikan register dari stack. Lihat ringkasan konvensi pemanggilan ini untuk melihat pekerjaan yang terlibat .

Tumpahan / pemulihan register ini membutuhkan waktu yang tidak sepele (puluhan siklus CPU).

Secara umum diharapkan bahwa biaya ini akan sepele dibandingkan dengan biaya aktual menjalankan fungsi, namun beberapa pola kontraproduktif di sini: getter, fungsi dijaga oleh kondisi sederhana, dll ...

Selain juru bahasa , seorang programmer akan berharap bahwa kompiler atau JIT mereka akan mengoptimalkan pemanggilan fungsi yang tidak perlu; meskipun harapan ini terkadang tidak membuahkan hasil. Karena pengoptimal bukanlah sihir.

Pengoptimal dapat mendeteksi bahwa panggilan fungsi adalah sepele, dan sebaris panggilan: pada dasarnya, salin / tempelkan badan fungsi di situs panggilan. Ini tidak selalu merupakan optimasi yang baik (dapat menyebabkan mengasapi) tetapi secara umum bermanfaat karena inlining memaparkan konteks , dan konteksnya memungkinkan lebih banyak optimisasi.

Contoh khas adalah:

void func(condition: boolean) {
    if (condition) {
        doLotsOfWork();
    }
}

void call() { func(false); }

Jika funcinline, maka optimizer akan menyadari bahwa cabang tidak pernah diambil, dan mengoptimalkan calluntuk void call() {}.

Dalam hal ini, pemanggilan fungsi, dengan menyembunyikan informasi dari pengoptimal (jika belum inline), dapat menghambat pengoptimalan tertentu. Panggilan fungsi virtual terutama bersalah karena ini, karena devirtualization (membuktikan fungsi mana yang akhirnya dipanggil pada saat run time) tidak selalu mudah.


Sebagai kesimpulan, saran saya adalah menulis dengan jelas terlebih dahulu, menghindari pesimisasi algoritme prematur (kompleksitas kubik atau gigitan yang lebih buruk dengan cepat), dan kemudian hanya mengoptimalkan apa yang perlu dioptimalkan.

Matthieu M.
sumber
1

"Ingat bahwa biaya pemanggilan metode bisa signifikan, tergantung pada bahasanya. Hampir selalu ada tradeoff antara menulis kode yang dapat dibaca dan menulis kode pemain."

Dalam kondisi apa pernyataan yang dikutip ini masih berlaku saat ini mengingat industri kaya dari kompiler modern yang berprestasi?

Aku hanya akan mengatakan tidak pernah. Saya percaya kutipan itu sembrono untuk dibuang begitu saja.

Tentu saja saya tidak berbicara kebenaran yang lengkap, tetapi saya tidak terlalu peduli tentang kebenaran sebanyak itu. Ini seperti dalam film Matrix, saya lupa apakah itu 1 atau 2 atau 3 - saya pikir itu adalah satu dengan aktris Italia seksi dengan melon besar (saya tidak benar-benar suka kecuali yang pertama), ketika Wanita oracle mengatakan kepada Keanu Reeves, "Saya baru saja mengatakan kepada Anda apa yang perlu Anda dengar," atau sesuatu untuk efek ini, itulah yang ingin saya lakukan sekarang.

Pemrogram tidak perlu mendengar ini. Jika mereka berpengalaman dengan profiler di tangan mereka dan kutipan itu agak berlaku untuk kompiler mereka, mereka akan sudah tahu ini dan akan belajar ini dengan cara yang tepat asalkan mereka memahami output profil mereka dan mengapa panggilan daun tertentu adalah hotspot, melalui pengukuran. Jika mereka tidak berpengalaman dan tidak pernah memrofilkan kode mereka, ini adalah hal terakhir yang perlu mereka dengar, bahwa mereka harus mulai dengan takhayul mengkompromikan bagaimana mereka menulis kode sampai pada titik menggarisbawahi segala sesuatu bahkan sebelum mengidentifikasi hotspot dengan harapan bahwa itu akan menjadi lebih performant.

Bagaimanapun, untuk respons yang lebih akurat, itu tergantung. Beberapa persyaratan kapal sudah terdaftar di antara jawaban yang bagus. Kondisi yang mungkin hanya memilih satu bahasa sudah sangat besar, seperti C ++ yang harus masuk ke pengiriman dinamis dalam panggilan virtual dan ketika itu dapat dioptimalkan jauh dan di bawah mana kompiler dan bahkan linker, dan yang sudah menjamin tanggapan rinci apalagi mencoba untuk mengatasi kondisi dalam setiap bahasa yang mungkin dan kompiler di luar sana. Tetapi saya akan menambahkan di atas, "siapa yang peduli?" karena bahkan bekerja di area yang sangat kritis terhadap kinerja seperti raytracing, hal terakhir yang akan saya mulai lakukan di muka adalah metode hand-inlining sebelum saya melakukan pengukuran.

Saya percaya beberapa orang terlalu bersemangat untuk menyarankan Anda tidak boleh melakukan optimasi mikro sebelum mengukur. Jika mengoptimalkan lokalitas jumlah referensi sebagai optimasi mikro, maka saya sering mulai menerapkan optimasi seperti itu di awal dengan pola pikir desain berorientasi data di bidang yang saya tahu pasti akan sangat penting untuk kinerja (kode raytracing, misalnya), karena kalau tidak saya tahu saya harus menulis ulang bagian besar segera setelah bekerja di domain ini selama bertahun-tahun. Mengoptimalkan representasi data untuk hit cache seringkali dapat memiliki jenis peningkatan kinerja yang sama dengan peningkatan algoritmik kecuali jika kita berbicara seperti waktu kuadratik untuk linier.

Tetapi saya tidak pernah melihat alasan yang baik untuk memulai inlining sebelum pengukuran, terutama karena profiler layak mengungkapkan apa yang mungkin mendapat manfaat dari inlining, tetapi tidak mengungkapkan apa yang mungkin mendapat manfaat dari tidak di-inline (dan tidak inlining sebenarnya dapat membuat kode lebih cepat jika panggilan fungsi tak bergaris adalah kasus yang jarang terjadi, meningkatkan lokalitas referensi untuk icache untuk kode panas dan kadang-kadang bahkan memungkinkan pengoptimal untuk melakukan pekerjaan yang lebih baik untuk jalur kasus umum eksekusi).


sumber