Dalam C ++ mengapa dan bagaimana fungsi virtual lebih lambat?

38

Adakah yang bisa menjelaskan secara rinci, bagaimana sebenarnya tabel virtual bekerja dan pointer apa yang dikaitkan ketika fungsi virtual dipanggil.

Jika mereka sebenarnya lebih lambat, dapatkah Anda menunjukkan waktu yang diperlukan untuk menjalankan fungsi virtual lebih dari metode kelas normal? Sangat mudah untuk kehilangan jejak bagaimana / apa yang terjadi tanpa melihat beberapa kode.

MdT
sumber
5
Mencari panggilan metode yang benar dari vtable jelas akan memakan waktu lebih lama daripada memanggil metode secara langsung, karena ada lebih banyak yang harus dilakukan. Berapa lama lagi, atau apakah waktu tambahan itu signifikan dalam konteks program Anda sendiri, adalah pertanyaan lain. en.wikipedia.org/wiki/Virtual_method_table
Robert Harvey
10
Lebih lambat dari apa sebenarnya? Saya telah melihat kode yang rusak, implementasi lambat dari perilaku dinamis dengan banyak pernyataan switch hanya karena beberapa programmer telah mendengar bahwa fungsi virtual lambat.
Christopher Creutzig
7
Seringkali, ini bukan karena panggilan virtual itu sendiri lambat, tetapi kompiler tidak memiliki kemampuan untuk menyejajarkannya.
Kevin Hsu
4
@ Kevin Hsu: ya ini benar-benar. Hampir setiap saat seseorang memberi tahu Anda bahwa mereka mendapat kecepatan dari menghilangkan "overhead panggilan fungsi virtual", jika Anda melihatnya dari mana semua speedup sebenarnya berasal dari optimisasi yang sekarang mungkin karena kompiler tidak dapat mengoptimalkan seluruh panggilan tak tentu sebelumnya.
timday
7
Bahkan orang yang dapat membaca kode perakitan tidak dapat secara akurat memprediksi overhead dalam eksekusi CPU yang sebenarnya. Pembuat CPU berbasis desktop telah berinvestasi dalam beberapa dekade penelitian dalam tidak hanya prediksi cabang, tetapi juga prediksi nilai dan eksekusi spekulatif untuk alasan utama menutupi latensi fungsi virtual. Mengapa? Karena OS desktop dan perangkat lunak banyak menggunakannya. (Saya tidak akan mengatakan hal yang sama tentang mobile CPU.)
rwong

Jawaban:

55

Metode virtual biasanya diimplementasikan melalui apa yang disebut tabel metode virtual (singkatnya vtable), di mana pointer fungsi disimpan. Ini menambahkan tipuan ke panggilan aktual (harus mengambil alamat fungsi untuk memanggil dari vtable, lalu memanggilnya - bukan hanya memanggilnya di depan). Tentu saja, ini membutuhkan waktu dan beberapa kode lagi.

Namun, itu belum tentu menjadi penyebab utama kelambatan. Masalah sebenarnya adalah bahwa kompiler (umumnya / biasanya) tidak dapat mengetahui fungsi mana yang akan dipanggil. Jadi itu tidak dapat menyejajarkan atau melakukan optimasi lainnya. Ini saja mungkin menambah selusin instruksi yang tidak berguna (menyiapkan register, memanggil, kemudian mengembalikan status setelahnya), dan mungkin menghambat optimasi lain yang tampaknya tidak terkait. Selain itu, jika Anda bercabang gila dengan memanggil banyak implementasi berbeda, Anda mengalami hit yang sama dengan yang Anda menderita percabangan seperti gila dengan cara lain: Cache dan prediktor cabang tidak akan membantu Anda, cabang-cabangnya akan memakan waktu lebih lama dari yang dapat diprediksi dengan sempurna cabang.

Besar tetapi : Hits kinerja ini biasanya terlalu kecil untuk diperhitungkan. Mereka patut dipertimbangkan jika Anda ingin membuat kode berkinerja tinggi dan mempertimbangkan untuk menambahkan fungsi virtual yang akan dipanggil pada frekuensi yang mengkhawatirkan. Namun, juga perlu diingat bahwa mengganti panggilan fungsi virtual dengan cara lain bercabang ( if .. else, switch, fungsi pointer, dll) tidak akan memecahkan masalah mendasar - mungkin sangat baik menjadi lebih lambat. Masalahnya (jika ada sama sekali) bukan fungsi virtual tetapi (tidak perlu) tipuan.

Sunting: Perbedaan dalam instruksi panggilan dijelaskan dalam jawaban lain. Pada dasarnya, kode untuk panggilan statis ("normal") adalah:

  • Salin beberapa register pada stack, untuk memungkinkan fungsi yang dipanggil menggunakan register tersebut.
  • Salin argumen ke lokasi yang telah ditentukan, sehingga fungsi yang dipanggil dapat menemukannya terlepas dari mana namanya.
  • Dorong alamat pengirim.
  • Cabang / lompat ke kode fungsi, yang merupakan alamat waktu kompilasi dan karenanya dikodekan dalam biner oleh kompiler / penghubung.
  • Dapatkan nilai pengembalian dari lokasi yang telah ditentukan dan pulihkan register yang ingin kita gunakan.

Panggilan virtual melakukan hal yang persis sama, kecuali bahwa alamat fungsi tidak diketahui pada waktu kompilasi. Sebagai gantinya, beberapa instruksi ...

  • Dapatkan pointer vtable, yang menunjuk ke array pointer fungsi (function address), satu untuk setiap fungsi virtual, dari objek.
  • Dapatkan alamat fungsi yang benar dari vtable ke register (indeks di mana alamat fungsi yang benar disimpan ditentukan pada waktu kompilasi).
  • Lompat ke alamat dalam register itu, daripada melompat ke alamat yang dikodekan dengan keras.

Adapun cabang: Cabang adalah apa pun yang melompat ke instruksi lain, bukan hanya membiarkan instruksi berikutnya dijalankan. Ini termasuk if,, switchbagian dari berbagai loop, pemanggilan fungsi, dll. Dan kadang-kadang kompiler mengimplementasikan hal-hal yang tampaknya tidak bercabang dengan cara yang benar-benar membutuhkan cabang di bawah tenda. Lihat Mengapa memproses array yang diurutkan lebih cepat daripada array yang tidak disortir? untuk alasan ini mungkin lambat, apa yang CPU lakukan untuk mengatasi perlambatan ini, dan bagaimana ini bukan obat untuk semua.

Komunitas
sumber
6
@ JörgWMittag mereka semua adalah juru bahasa, dan mereka masih lebih lambat dari kode biner yang dihasilkan oleh kompiler C ++
Sam
13
@ JörgWMittag Optimalisasi ini ada untuk membuat tipuan / keterlambatan mengikat (hampir) gratis ketika tidak diperlukan , karena dalam bahasa-bahasa tersebut setiap panggilan secara teknis terlambat. Jika Anda benar-benar memanggil banyak metode virtual yang berbeda dari satu tempat dalam waktu singkat, optimasi ini tidak membantu atau secara aktif melukai (membuat banyak kode untuk sia-sia). C ++ guys tidak terlalu tertarik pada optimasi tersebut karena mereka berada dalam situasi yang sangat berbeda ...
10
@ JörgWMittag ... C ++ guys tidak terlalu tertarik pada optimasi itu karena mereka berada dalam situasi yang sangat berbeda: Cara vtable yang dikompilasi AOT sudah cukup cepat, sangat sedikit panggilan yang sebenarnya virtual, banyak kasus polimorfisme lebih awal- terikat (melalui templat) dan karenanya dapat diperbaiki untuk pengoptimalan AOT. Akhirnya, melakukan optimasi ini secara adaptif (bukan hanya berspekulasi pada waktu kompilasi) membutuhkan pembuatan kode run-time, yang memperkenalkan banyak sakit kepala. Kompiler JIT telah memecahkan masalah tersebut karena alasan lain, jadi mereka tidak keberatan, tetapi kompiler AOT ingin menghindarinya.
3
jawaban bagus, +1. Satu hal yang perlu diperhatikan adalah kadang-kadang hasil dari percabangan diketahui pada waktu kompilasi, misalnya ketika Anda menulis kelas framework yang perlu mendukung penggunaan yang berbeda tetapi begitu kode aplikasi berinteraksi dengan kelas-kelas tersebut, penggunaan spesifik tersebut sudah diketahui. Dalam hal ini, alternatif untuk fungsi virtual, bisa berupa templat C ++. Contoh yang baik adalah CRTP, yang mengemulasi perilaku fungsi virtual tanpa vtables: en.wikipedia.org/wiki/Curiously_recurring_template_pattern
DXM
3
@ James Anda benar. Apa yang saya coba katakan adalah: Setiap tipuan memiliki masalah yang sama, tidak ada yang spesifik untuk virtual.
23

Berikut ini beberapa kode dibongkar yang sebenarnya dari panggilan fungsi virtual dan panggilan non-virtual, masing-masing:

mov    -0x8(%rbp),%rax
mov    (%rax),%rax
mov    (%rax),%rax
callq  *%rax

callq  0x4007aa

Anda dapat melihat bahwa panggilan virtual memerlukan tiga instruksi tambahan untuk mencari alamat yang benar, sedangkan alamat panggilan non-virtual dapat dikompilasi.

Namun, perhatikan bahwa sebagian besar waktu bahwa waktu pencarian tambahan dapat dianggap diabaikan. Dalam situasi di mana waktu pencarian akan signifikan, seperti dalam satu lingkaran, nilainya biasanya dapat di-cache dengan melakukan tiga instruksi pertama sebelum loop.

Situasi lain di mana waktu pencarian menjadi signifikan adalah jika Anda memiliki koleksi objek dan Anda mengulang melalui memanggil fungsi virtual pada masing-masing. Namun, dalam hal ini, Anda akan memerlukan beberapa cara untuk memilih fungsi mana yang akan dipanggil, dan pencarian tabel virtual sama baiknya dengan cara apa pun. Bahkan, karena kode pencarian vtable sangat banyak digunakan, sangat dioptimalkan, jadi mencoba untuk mengatasinya secara manual memiliki peluang bagus untuk menghasilkan kinerja yang lebih buruk .

Karl Bielefeldt
sumber
1
Hal yang perlu dipahami adalah bahwa pencarian vtable dan panggilan tidak langsung dalam hampir semua kasus akan berdampak kecil pada total waktu berjalan dari metode yang dipanggil.
John R. Strohm
12
@ JohnR.Strohm Satu orang dapat diabaikan adalah hambatan bagi orang lain
James
1
-0x8(%rbp). oh my ... itu sintaks AT&T.
Abyx
" tiga instruksi tambahan " tidak, hanya dua: memuat vptr dan memuat pointer fungsi
curiousguy
@curiousguy sebenarnya adalah tiga instruksi tambahan. Anda lupa bahwa metode virtual selalu dipanggil pada pointer , jadi Anda harus terlebih dahulu memuat pointer ke register. Singkatnya, langkah pertama adalah memuat alamat yang disimpan variabel pointer ke register% rax, kemudian menurut alamat dalam register, muat vtpr pada alamat ini untuk mendaftarkan% rax, kemudian menurut alamat ini di daftar, muat alamat metode yang akan dipanggil ke% rax, lalu callq *% rax !.
Gab 是 好人
18

Lebih lambat dari apa ?

Fungsi virtual memecahkan masalah yang tidak dapat diselesaikan dengan panggilan fungsi langsung. Secara umum, Anda hanya dapat membandingkan dua program yang menghitung hal yang sama. "Pelacak ray ini lebih cepat daripada kompiler itu" tidak masuk akal, dan prinsip ini menggeneralisasi bahkan untuk hal-hal kecil seperti fungsi individu atau konstruksi bahasa pemrograman.

Jika Anda tidak menggunakan fungsi virtual untuk secara dinamis beralih ke sepotong kode berdasarkan datum, seperti jenis objek, maka Anda harus menggunakan sesuatu yang lain, seperti switchpernyataan untuk mencapai hal yang sama. Sesuatu yang lain memiliki overhead sendiri, ditambah implikasi pada organisasi program yang memengaruhi kelestariannya dan kinerja global.

Perhatikan bahwa dalam C ++, panggilan ke fungsi virtual tidak selalu dinamis. Ketika panggilan dilakukan pada objek yang tipe pastinya diketahui (karena objek tersebut bukan pointer atau referensi, atau karena tipenya dapat disimpulkan secara statis), maka panggilan tersebut hanyalah panggilan fungsi anggota biasa. Itu tidak hanya berarti bahwa tidak ada overhead pengiriman, tetapi juga bahwa panggilan ini dapat digariskan dengan cara yang sama seperti panggilan biasa.

Dengan kata lain, kompiler C ++ Anda dapat berfungsi ketika fungsi virtual tidak memerlukan pengiriman virtual, jadi biasanya tidak ada alasan untuk khawatir tentang kinerjanya relatif terhadap fungsi non-virtual.

Baru: Juga, kita tidak boleh melupakan perpustakaan bersama. Jika Anda menggunakan kelas yang ada di pustaka bersama, panggilan ke fungsi anggota biasa tidak hanya berupa urutan instruksi yang bagus callq 0x4007aa. Itu harus melalui beberapa simpai, seperti tidak langsung melalui "tabel tautan program" atau struktur semacam itu. Oleh karena itu, tipuan perpustakaan bersama agak bisa (jika tidak sepenuhnya) tingkat perbedaan biaya antara panggilan virtual (benar-benar tidak langsung) dan panggilan langsung. Jadi alasan tentang pengorbanan fungsi virtual harus mempertimbangkan bagaimana program dibangun: apakah kelas objek target secara monolitik terhubung ke program yang melakukan panggilan.

Kaz
sumber
4
"Lebih lambat dari apa?" - jika Anda membuat metode virtual yang tidak harus, Anda memiliki bahan perbandingan yang cukup bagus.
tdammers
2
Terima kasih telah menunjukkan bahwa panggilan ke fungsi virtual tidak selalu dinamis. Setiap respons lain di sini membuatnya tampak seperti mendeklarasikan fungsi virtual yang berarti hit kinerja otomatis, terlepas dari keadaan apa pun.
Syndog
12

karena panggilan virtual setara dengan

res_t (*foo)(arg_t);
foo = (obj->vtable[foo_offset]);
foo(obj,args)

di mana dengan fungsi non-virtual kompiler dapat secara konstan melipat baris pertama, ini adalah dereferensi tambahan dan panggilan dinamis diubah menjadi hanya panggilan statis

ini juga memungkinkan fungsi inline (dengan semua konsekuensi optimasi karena)

ratchet freak
sumber