Saya ingin masuk ke sini di antara jawaban-jawaban yang sudah sangat bagus ini dan mengakui bahwa saya telah mengambil pendekatan yang jelek untuk benar-benar bekerja mundur ke anti-pola mengubah kode polimorfik menjadi switches
atau if/else
cabang dengan keuntungan yang terukur. Tapi saya tidak melakukan grosir ini, hanya untuk jalur paling kritis. Tidak harus hitam dan putih.
Sebagai penafian, saya bekerja di bidang-bidang seperti raytracing di mana kebenaran tidak begitu sulit untuk dicapai (dan seringkali fuzzy dan didekati pula) sementara kecepatan sering menjadi salah satu kualitas paling kompetitif yang dicari. Pengurangan waktu render seringkali merupakan salah satu permintaan pengguna yang paling umum, dengan kami terus-menerus menggaruk-garuk kepala kami dan mencari cara untuk mencapainya untuk jalur terukur yang paling kritis.
Refactoring Polimorfik Kondisional
Pertama, perlu dipahami mengapa polimorfisme lebih disukai dari aspek rawatan daripada percabangan bersyarat ( switch
atau banyak if/else
pernyataan). Manfaat utama di sini adalah ekstensibilitas .
Dengan kode polimorfik, kami dapat memperkenalkan subtipe baru ke basis kode kami, menambahkan contohnya ke beberapa struktur data polimorfik, dan memiliki semua kode polimorfik yang ada yang masih bekerja secara otomatis tanpa modifikasi lebih lanjut. Jika Anda memiliki banyak kode yang tersebar di seluruh basis kode besar yang menyerupai bentuk, "Jika jenis ini adalah 'foo', lakukan itu" , Anda mungkin menemukan diri Anda dengan beban yang mengerikan untuk memperbarui 50 bagian kode yang berbeda untuk memperkenalkan jenis hal baru, dan akhirnya hilang beberapa.
Manfaat rawatan polimorfisme secara alami berkurang di sini jika Anda hanya memiliki pasangan atau bahkan satu bagian dari basis kode Anda yang perlu melakukan pemeriksaan jenis tersebut.
Penghalang Pengoptimalan
Saya sarankan untuk tidak melihat ini dari sudut pandang percabangan dan pipelining begitu banyak, dan melihatnya lebih dari pola pikir desain kompiler dari hambatan optimasi. Ada beberapa cara untuk meningkatkan prediksi cabang yang berlaku untuk kedua kasus, seperti mengurutkan data berdasarkan sub-tipe (jika cocok dengan urutan).
Apa yang lebih berbeda antara kedua strategi ini adalah jumlah informasi yang dimiliki pengoptimal sebelumnya. Panggilan fungsi yang diketahui menyediakan lebih banyak informasi, panggilan fungsi tidak langsung yang memanggil fungsi yang tidak dikenal pada waktu kompilasi mengarah ke penghalang optimasi.
Ketika fungsi yang dipanggil diketahui, kompiler dapat melenyapkan struktur dan memadatkannya menjadi berkeping-keping, menyatukan panggilan, menghilangkan potensi overhead aliasing, melakukan pekerjaan yang lebih baik dengan alokasi instruksi / register, bahkan mungkin menata ulang loop dan bentuk cabang lainnya, menghasilkan hard LUT miniatur yang disandikan bila diperlukan (sesuatu yang GCC 5.3 baru-baru ini mengejutkan saya dengan switch
pernyataan dengan menggunakan LUT kode-data untuk hasil daripada tabel lompatan).
Beberapa manfaat tersebut hilang ketika kami mulai memperkenalkan waktu kompilasi yang tidak diketahui ke dalam campuran, seperti halnya pemanggilan fungsi tidak langsung, dan di situlah percabangan bersyarat kemungkinan besar menawarkan keunggulan.
Optimalisasi Memori
Ambil contoh gim video yang terdiri dari pemrosesan urutan makhluk berulang kali dalam satu lingkaran yang ketat. Dalam kasus seperti itu, kita mungkin memiliki beberapa wadah polimorfik seperti ini:
vector<Creature*> creatures;
Catatan: untuk kesederhanaan saya hindari di unique_ptr
sini.
... di mana Creature
adalah tipe dasar polimorfik. Dalam hal ini, salah satu kesulitan dengan wadah polimorfik adalah bahwa mereka sering ingin mengalokasikan memori untuk setiap subtipe secara terpisah / individual (mis: menggunakan lemparan default operator new
untuk setiap makhluk individu).
Itu akan sering membuat prioritas pertama untuk optimasi (jika kita membutuhkannya) berbasis memori daripada percabangan. Salah satu strategi di sini adalah menggunakan pengalokasi tetap untuk setiap sub-jenis, mendorong representasi yang berdekatan dengan mengalokasikan dalam potongan besar dan menyatukan memori untuk setiap sub-jenis yang dialokasikan. Dengan strategi seperti itu, pasti dapat membantu untuk menyortir creatures
wadah ini menurut sub-jenis (dan juga alamat), karena hal itu tidak hanya mungkin meningkatkan prediksi cabang tetapi juga meningkatkan lokalitas referensi (memungkinkan beberapa makhluk dengan subtipe yang sama untuk diakses dari satu baris cache sebelum penggusuran).
Devirtualisasi Parsial Struktur Data dan Loop
Katakanlah Anda melakukan semua gerakan ini dan Anda masih menginginkan kecepatan yang lebih. Perlu dicatat bahwa setiap langkah yang kami lakukan di sini menurunkan tingkat perawatan, dan kami akan berada pada tahap penggilingan logam dengan pengembalian kinerja yang semakin berkurang. Jadi perlu ada permintaan kinerja yang cukup signifikan jika kita melangkah ke wilayah ini, di mana kami bersedia mengorbankan pemeliharaan lebih jauh untuk keuntungan kinerja yang lebih kecil dan lebih kecil.
Namun langkah selanjutnya untuk mencoba (dan selalu dengan kemauan untuk mendukung perubahan kita jika tidak membantu sama sekali) mungkin adalah devirtualization manual.
Kiat kendali versi: kecuali Anda jauh lebih mengerti optimasi daripada saya, ada baiknya membuat cabang baru pada saat ini dengan kemauan untuk membuangnya jika upaya optimasi kami kehilangan yang mungkin terjadi. Bagi saya itu semua coba-coba setelah titik-titik semacam ini bahkan dengan profiler di tangan.
Namun demikian, kita tidak harus menerapkan pola pikir ini secara grosir. Melanjutkan contoh kita, katakanlah video game ini sebagian besar terdiri dari makhluk manusia, sejauh ini. Dalam kasus seperti itu, kita hanya dapat mendevirtualisasi makhluk manusia dengan mengangkatnya dan membuat struktur data terpisah hanya untuk mereka.
vector<Human> humans; // common case
vector<Creature*> other_creatures; // additional rare-case creatures
Ini menyiratkan bahwa semua area dalam basis kode kami yang perlu memproses makhluk membutuhkan loop kasus khusus untuk makhluk manusia. Namun itu menghilangkan overhead pengiriman dinamis (atau mungkin, lebih tepat, penghalang optimasi) bagi manusia yang, sejauh ini, adalah jenis makhluk yang paling umum. Jika area ini besar jumlahnya dan kami mampu membelinya, kami mungkin melakukan ini:
vector<Human> humans; // common case
vector<Creature*> other_creatures; // additional rare-case creatures
vector<Creature*> creatures; // contains humans and other creatures
... jika kita mampu melakukan ini, jalur yang kurang kritis dapat tetap seperti itu dan hanya memproses semua jenis makhluk secara abstrak. Jalur kritis dapat memproses humans
dalam satu loop dan other_creatures
dalam loop kedua.
Kami dapat memperluas strategi ini sesuai kebutuhan dan berpotensi memeras beberapa keuntungan dengan cara ini, namun perlu dicatat seberapa banyak kami merendahkan kemampuan pemeliharaan dalam proses tersebut. Menggunakan templat fungsi di sini dapat membantu menghasilkan kode untuk manusia dan makhluk tanpa menduplikasi logikanya secara manual.
Devirtualization Sebagian Kelas
Sesuatu yang saya lakukan bertahun-tahun lalu yang benar-benar menjijikkan, dan saya bahkan tidak yakin itu bermanfaat lagi (ini di era C ++ 03), adalah devirtualisasi parsial suatu kelas. Dalam hal ini, kami sudah menyimpan ID kelas dengan setiap instance untuk tujuan lain (diakses melalui accessor di kelas dasar yang non-virtual). Di sana kami melakukan sesuatu yang analog dengan ini (ingatanku agak kabur):
switch (obj->type())
{
case id_common_type:
static_cast<CommonType*>(obj)->non_virtual_do_something();
break;
...
default:
obj->virtual_do_something();
break;
}
... di mana virtual_do_something
diterapkan untuk memanggil versi non-virtual dalam subkelas. Ini kotor, saya tahu, melakukan downcast statis eksplisit untuk mendevirtualize panggilan fungsi. Saya tidak tahu betapa bermanfaatnya ini sekarang karena saya belum pernah mencoba hal semacam ini selama bertahun-tahun. Dengan paparan desain berorientasi data, saya menemukan strategi di atas memecah struktur data dan loop dalam mode panas / dingin menjadi jauh lebih berguna, membuka lebih banyak pintu untuk strategi optimasi (dan jauh lebih jelek).
Devirtualisasi Grosir
Saya harus mengakui bahwa saya tidak pernah sejauh ini menerapkan pola pikir optimasi, jadi saya tidak tahu manfaatnya. Saya telah menghindari fungsi tidak langsung dalam tinjauan ke masa depan dalam kasus-kasus di mana saya tahu hanya akan ada satu set kondisional sentral (mis: pemrosesan acara dengan hanya satu acara pemrosesan tempat sentral), tetapi tidak pernah memulai dengan pola pikir polimorfik dan dioptimalkan sepanjang jalan. sampai sini.
Secara teoritis, manfaat langsung di sini mungkin merupakan cara yang berpotensi lebih kecil untuk mengidentifikasi jenis daripada penunjuk virtual (mis: satu byte jika Anda dapat berkomitmen pada gagasan bahwa ada 256 jenis unik atau kurang) selain benar-benar menghilangkan hambatan pengoptimalan ini. .
Dalam beberapa kasus mungkin juga membantu untuk menulis kode yang lebih mudah dirawat (dibandingkan contoh devirtualisasi manual yang dioptimalkan di atas) jika Anda hanya menggunakan satu switch
pernyataan pusat tanpa harus membagi struktur data dan loop berdasarkan subtipe, atau jika ada pesanan -dependensi dalam kasus ini di mana hal-hal harus diproses dalam urutan yang tepat (bahkan jika itu menyebabkan kami bercabang di semua tempat). Ini akan menjadi kasus di mana Anda tidak memiliki terlalu banyak tempat yang perlu dilakukan switch
.
Saya umumnya tidak akan merekomendasikan ini bahkan dengan pola pikir yang sangat kritis terhadap kinerja kecuali ini cukup mudah untuk dipertahankan. "Mudah dirawat" cenderung bergantung pada dua faktor dominan:
- Tidak memiliki kebutuhan ekstensibilitas yang nyata (mis: mengetahui dengan pasti bahwa Anda memiliki 8 jenis hal yang harus diproses, dan tidak pernah lagi).
- Tidak memiliki banyak tempat dalam kode Anda yang perlu memeriksa jenis ini (mis: satu tempat sentral).
... namun saya merekomendasikan skenario di atas dalam banyak kasus dan beralih ke solusi yang lebih efisien dengan devirtualization parsial sesuai kebutuhan. Ini memberi Anda lebih banyak ruang bernapas untuk menyeimbangkan kebutuhan perpanjangan dan pemeliharaan dengan kinerja.
Fungsi Virtual vs. Function Pointer
Untuk melengkapi ini, saya perhatikan di sini bahwa ada beberapa diskusi tentang fungsi virtual vs fungsi pointer. Memang benar bahwa fungsi virtual memerlukan sedikit kerja ekstra untuk memanggil, tetapi itu tidak berarti mereka lebih lambat. Kontra-intuitif, bahkan mungkin membuat mereka lebih cepat.
Ini kontra-intuitif di sini karena kita terbiasa mengukur biaya dalam hal instruksi tanpa memperhatikan dinamika hierarki memori yang cenderung memiliki dampak yang jauh lebih signifikan.
Jika kita membandingkan a class
dengan 20 fungsi virtual vs. struct
yang menyimpan 20 fungsi pointer, dan keduanya instantiated beberapa kali, overhead memori dari setiap class
instance dalam hal ini 8 byte untuk pointer virtual pada mesin 64-bit, sedangkan memori overhead struct
adalah 160 byte.
Biaya praktis bisa ada jauh lebih banyak cache wajib dan non-wajib dengan tabel pointer fungsi vs kelas menggunakan fungsi virtual (dan mungkin kesalahan halaman pada skala input yang cukup besar). Biaya itu cenderung membuat pekerjaan pengindeksan tabel virtual sedikit lebih kecil.
Saya juga telah berurusan dengan basis kode C warisan (lebih tua dari saya) di mana mengubah structs
diisi dengan pointer fungsi, dan dipakai berkali-kali, benar-benar memberikan keuntungan kinerja yang signifikan (lebih dari 100% peningkatan) dengan mengubahnya menjadi kelas dengan fungsi virtual, dan hanya karena pengurangan besar dalam penggunaan memori, peningkatan cache-keramahan, dll.
Di sisi lain, ketika perbandingan menjadi lebih tentang apel ke apel, saya juga telah menemukan pola pikir yang berlawanan dari menerjemahkan dari pola pikir fungsi virtual C ++ ke pola fungsi pointer gaya C untuk menjadi berguna dalam jenis skenario ini:
class Functionoid
{
public:
virtual ~Functionoid() {}
virtual void operator()() = 0;
};
... di mana kelas menyimpan fungsi tunggal yang sangat dapat dikesampingkan (atau dua jika kita menghitung destruktor virtual). Dalam kasus-kasus itu, pasti dapat membantu dalam jalur kritis untuk mengubahnya menjadi ini:
void (*func_ptr)(void* instance_data);
... idealnya di belakang antarmuka tipe-aman untuk menyembunyikan gips berbahaya ke / dari void*
.
Dalam kasus-kasus di mana kita tergoda untuk menggunakan kelas dengan fungsi virtual tunggal, dapat dengan cepat membantu menggunakan pointer fungsi sebagai gantinya. Alasan besar bahkan belum tentu mengurangi biaya dalam memanggil fungsi pointer. Itu karena kita tidak lagi menghadapi godaan untuk mengalokasikan masing-masing functionoid terpisah pada daerah tumpukan yang tersebar jika kita menggabungkannya ke dalam struktur yang persisten. Pendekatan semacam ini dapat membuatnya lebih mudah untuk menghindari heap-related dan fragmentasi memori overhead jika data instance homogen, misalnya, dan hanya perilaku yang bervariasi.
Jadi pasti ada beberapa kasus di mana menggunakan pointer fungsi dapat membantu, tetapi sering saya menemukannya sebaliknya jika kita membandingkan sekelompok tabel pointer fungsi ke satu vtable yang hanya memerlukan satu pointer disimpan per instance kelas. . Vtable itu akan sering duduk di satu atau lebih baris cache L1 juga dalam loop ketat.
Kesimpulan
Jadi, itu adalah putaran kecil saya tentang topik ini. Saya sarankan bertualang di area ini dengan hati-hati. Pengukuran kepercayaan, bukan insting, dan mengingat cara optimasi ini sering menurunkan rawatan, hanya sejauh yang Anda mampu (dan rute yang bijaksana adalah untuk berbuat salah di sisi rawatan).