Genel olarak, dallanmayı önlemek için sanal işlevleri kullanmaya değer mi?


21

Sanal fonksiyonların benzer bir çıkışa sahip olduğu bir dalın maliyetine eşit olmak için talimatların kaba bir eşdeğeri var gibi görünüyor:

  • talimat ve veri önbellek kaçırma
  • optimizasyon bariyeri

Şuna benzer bir şeye bakarsanız:

if (x==1) {
   p->do1();
}
else if (x==2) {
   p->do2();
}
else if (x==3) {
   p->do3();
}
...

Bir üye işlev diziniz olabilir veya birçok işlev aynı kategorileştirmeye bağlıysa veya daha karmaşık kategorizasyon varsa, sanal işlevleri kullanın:

p->do()

Ancak, genel olarak, sanal işlevlerin dallara karşı ne kadar pahalı olduğu Genelleştirmek için yeterli platformda test etmek zordur, bu yüzden herhangi birinin kaba bir kuralına sahip olup olmadığını merak ettim (4 ifs kadar basit olsaydı hoş bir kırılma noktası)

Genelde sanal işlevler daha açıktır ve onlara doğru eğilirdim. Ancak, kodu sanal işlevlerden dallara değiştirebileceğim son derece kritik bölümlerim var. Bunu yapmadan önce bu konuda düşüncelerim olmasını tercih ederim. (önemsiz bir değişiklik değildir veya birden fazla platformda test edilmesi kolaydır)


12
Performans gereksinimleriniz neler? Vurmanız gereken sabit sayılar mı var yoksa erken optimizasyonla mı uğraşıyorsunuz? Hem dallanma hem de sanal yöntemler, şeylerin genel şemasında son derece ucuzdur (örneğin, kötü algoritmalar, G / Ç veya yığın tahsisi ile karşılaştırıldığında).
amon

4
Gelecekteki değişikliklerin önüne geçmek için daha okunabilir / esnek / olası olmayan şeyleri yapın ve çalıştıktan sonra profilleme yapın ve bunun gerçekten önemli olup olmadığını görün. Genellikle değildir.
Ixrec

1
Soru: "Ama, genel olarak, sanal işlevler ne kadar pahalı ..." Cevap: Dolaylı şube (wikipedia)
rwong

1
Yanıtların çoğunun, talimatların sayısını saymaya dayalı olduğunu unutmayın. Düşük seviye kod optimize edici olarak, talimatların sayısına güvenmiyorum; deneysel koşullar altında - fiziksel olarak - belirli bir CPU mimarisinde kanıtlamanız gerekir. Bu soru için geçerli cevaplar teorik değil ampirik ve deneysel olmalıdır.
rwong

3
Bu sorudaki sorun, bunun endişelenecek kadar büyük olduğunu varsaymasıdır. Gerçek yazılımda, performans sorunları, çok boyutlu pizza dilimleri gibi büyük parçalar halinde gelir. Örneğin buraya bakın . En büyük sorunun ne olduğunu bildiğinizi varsaymayın - programın size söylemesine izin verin. Bunu düzeltin ve sonra bir sonrakinin ne olduğunu söylemesine izin verin. Bunu yarım düzine kez yapın ve sanal işlev çağrılarının endişelenmeye değer olduğu yerde olabilirsiniz . Asla benim tecrübelerime göre.
Mike Dunlavey

Yanıtlar:


21

Zaten mükemmel olan bu cevaplar arasına atlamak istedim ve polimorf kodun değiştirilen anti-paternine switchesveya if/elseölçülen kazançlara sahip dallara karşı aslında çirkin bir yaklaşım benimsediğimi itiraf ettim . Ama bu toptancıyı yapmadım, sadece en kritik yollar için. O kadar siyah ve beyaz olmak zorunda değil.

Bir feragatname olarak, hız genellikle aranan en rekabetçi niteliklerden biri iken, doğruluğun elde edilmesinin çok zor olmadığı (ve genellikle de bulanık ve yaklaşık olarak tahmin edilen) ışın izleme gibi alanlarda çalışıyorum. Oluşturma sürelerinde bir azalma genellikle en yaygın kullanıcı taleplerinden biridir, sürekli olarak kafalarımızı kaşıyor ve en kritik ölçülen yollar için bunu nasıl başaracağımızı buluyoruz.

Şartların Polimorfik Yeniden Düzenlenmesi

İlk olarak, polimorfizmin koşullu dallanmadan ( switchveya bir grup if/elseifadeden) bir sürdürülebilirlik açısından neden tercih edilebileceğini anlamaya değer . Buradaki ana fayda genişletilebilirliktir .

Polimorfik kod ile, kod tabanımıza yeni bir alt tip ekleyebilir, bazı polimorfik veri yapısına örnekler ekleyebilir ve mevcut tüm polimorfik kodun başka bir değişiklik yapmadan otomatik olarak çalışmasını sağlayabiliriz. "Bu tür 'foo' ise, bunu yapın" biçimine benzeyen büyük bir kod tabanına dağılmış bir sürü kodunuz varsa , kendinizi tanıtmak için 50 farklı kod bölümünü güncellemenin korkunç bir yüküyle karşılaşabilirsiniz. yeni bir tür şey, ve yine de birkaç eksik.

Kod tabanınızın bu tür denetimleri yapması gereken bir çift veya hatta bir bölümünüz varsa, polimorfizmin sürdürülebilirlik faydaları doğal olarak azalır.

Optimizasyon Bariyeri

Buna dallanma ve boru hattı açısından çok bakmamanızı ve daha çok optimizasyon bariyerlerinin derleyici tasarım zihniyetinden bakmayı öneririm. Verileri alt türe göre sıralama (sıraya uyuyorsa) gibi her iki durum için de geçerli olan şube tahminini iyileştirmenin yolları vardır.

Bu iki strateji arasında daha farklı olan şey, optimize edicinin önceden sahip olduğu bilgi miktarıdır. Bilinen bir fonksiyon çağrısı çok daha fazla bilgi sağlar, derleme zamanında bilinmeyen bir fonksiyonu çağıran dolaylı bir fonksiyon çağrısı bir optimizasyon bariyerine yol açar.

Çağrılan işlev bilindiğinde, derleyiciler yapıyı yok edebilir ve smithereens'e ezebilir, çağrıları yönlendirebilir, potansiyel örtüşme yükünü ortadan kaldırabilir, talimat / kayıt tahsisinde daha iyi bir iş yapabilir, muhtemelen döngüler ve diğer dal formlarını yeniden düzenleyebilir, hatta sert üretebilir uygun olduğunda kodlanmış minyatür LUT'lar (bir şey GCC 5.3 switch, sonuçlar için bir atlama tablosu yerine sabit kodlu bir LUT kullanarak bir deyimle beni şaşırttı ).

Dolaylı bir işlev çağrısında olduğu gibi, karışıma derleme zamanı bilinmeyenlerini tanıtmaya başladığımızda bu avantajlardan bazıları kaybolur ve koşullu dallanma büyük olasılıkla bir avantaj sağlayabilir.

Bellek Optimizasyonu

Sıkı bir döngüde art arda gelen bir yaratık dizisinden oluşan bir video oyunu örneği alın. Böyle bir durumda, bunun gibi bazı polimorfik bir kabımız olabilir:

vector<Creature*> creatures;

Not: basitlik için unique_ptrburada kaçındım .

... Creaturepolimorfik baz tipi nerede . Bu durumda, polimorfik kaplarla ilgili zorluklardan biri, genellikle her alt tip için ayrı ayrı / ayrı ayrı bellek ayırmak istemeleridir (örn: operator newher bir canlı için varsayılan fırlatma kullanarak ).

Bu genellikle, dallanma yerine bellek tabanlı optimizasyon için ilk önceliklendirmeyi yapar (buna ihtiyacımız olursa). Buradaki bir strateji, her bir alt tip için sabit bir ayırıcı kullanmak, büyük parçalar halinde ayrılarak ve tahsis edilen her alt tip için belleği bir araya getirerek bitişik bir temsili teşvik etmektir. Böyle bir stratejiyle, bu creatureskapsayıcıyı alt türe (adresin yanı sıra) göre sıralamaya kesinlikle yardımcı olabilir , çünkü bu sadece şube tahminini iyileştirmekle kalmaz, aynı zamanda referans yerini de geliştirir (aynı alt türün birden fazla yaratığına erişilmesine izin verir) tahliye öncesi tek bir önbellek satırından).

Veri Yapılarının ve Döngülerinin Kısmi Devrileştirilmesi

Diyelim ki tüm bu hareketlerden geçtiniz ve hala daha fazla hız istiyorsunuz. Burada girişimde bulunduğumuz her adımın sürdürülebilirliği bozduğu ve performansın geri dönüşünün azalmasıyla biraz metal taşlama aşamasında olacağımızı belirtmek gerekir. Bu nedenle, daha küçük ve daha küçük performans kazançları için sürdürülebilirliği daha da feda etmeye istekli olduğumuz bu bölgeye girersek oldukça önemli bir performans talebi olması gerekiyor.

Yine de denemek için bir sonraki adım (ve her zaman yardımcı olmazsa değişikliklerimizi geri çekme istekliliğiyle ) manuel olarak sanallaştırma olabilir .

Sürüm kontrolü ipucu: benden çok daha fazla optimizasyon meraklısı değilseniz, optimizasyon çabalarımız çok iyi olabiliyorsa, atmak için istekli olan bu noktada yeni bir şube oluşturmaya değer olabilir. Benim için her şey, bu tür noktalardan sonra bile bir profiler olsa bile deneme yanılma.

Bununla birlikte, bu zihniyet toptancılığını uygulamak zorunda değiliz. Örneğimize devam edelim, bu video oyununun çoğunlukla insan yaratıklarından oluştuğunu varsayalım. Böyle bir durumda, yalnızca insan yaratıklarını kaldırarak ve sadece onlar için ayrı bir veri yapısı oluşturarak devredebiliriz.

vector<Human> humans;               // common case
vector<Creature*> other_creatures;  // additional rare-case creatures

Bu, kod tabanımızda yaratıkları işlemesi gereken tüm alanların, insan yaratıkları için ayrı bir özel durum döngüsüne ihtiyaç duyduğu anlamına gelir. Yine de bu, en yaygın yaratık türü olan insanlar için dinamik dağıtım yükünü (veya belki de daha uygun bir şekilde optimizasyon bariyerini) ortadan kaldırır. Bu alanların sayısı çok fazlaysa ve bunu karşılayabilirsek, bunu yapabiliriz:

vector<Human> humans;               // common case
vector<Creature*> other_creatures;  // additional rare-case creatures
vector<Creature*> creatures;        // contains humans and other creatures

... bunu karşılayabilirsek, daha az kritik yollar oldukları gibi kalabilir ve tüm yaratık türlerini soyut olarak işleyebilir. Kritik yollar humansbir döngüde ve other_creaturesikinci döngüde işlenebilir .

Bu stratejiyi gerektiği şekilde genişletebilir ve potansiyel olarak bu şekilde bazı kazançları sıkabiliriz, ancak süreçteki sürdürülebilirliği ne kadar aşağı çektiğimizi belirtmek gerekir. İşlev şablonlarını burada kullanmak, mantığı manuel olarak çoğaltmadan hem insanlar hem de yaratıklar için kod oluşturmaya yardımcı olabilir.

Sınıfların Kısmi Devrileştirilmesi

Yıllar önce yaptığım bir şey gerçekten iğrençti ve artık yararlı olduğuna emin değilim (bu C ++ 03 döneminde), bir sınıfın kısmi olarak yeniden yapılandırılmasıydı. Bu durumda, zaten başka bir amaçla (temel sınıfta sanal olmayan bir erişimci aracılığıyla erişilir) bir sınıf kimliği saklıyorduk. Orada buna benzer bir şey yaptık (hafızam biraz puslu):

switch (obj->type())
{
   case id_common_type:
       static_cast<CommonType*>(obj)->non_virtual_do_something();
       break;
   ...
   default:
       obj->virtual_do_something();
       break;
}

... virtual_do_somethingalt sınıfta sanal olmayan sürümleri çağırmak için uygulandı. Brüt, biliyorum, bir işlev çağrısını devralize etmek için açık bir statik downcast yapmak. Yıllardır bu tür şeyleri denemediğim için bunun ne kadar yararlı olduğu hakkında hiçbir fikrim yok. Veri odaklı tasarıma maruz kaldığımda, veri yapılarını ve döngüleri sıcak / soğuk bir şekilde bölme stratejisinin çok daha yararlı olduğunu, optimizasyon stratejileri için daha fazla kapı açtığını (ve çok daha az çirkin) buldum.

Toptan Devrileştirme

Şimdiye kadar hiç optimizasyon zihniyetini uygulayamadığımı itiraf etmeliyim, bu yüzden faydaları hakkında hiçbir fikrim yok. Sadece bir merkezi koşul kümesi olacağını bildiğim durumlarda öngörüde dolaylı işlevlerden kaçındım (örn: tek bir merkezi yer işleme olayı ile olay işleme), ancak hiçbir zaman polimorfik bir zihniyetle başlamadı ve tümüyle optimize edildi buraya kadar.

Teorik olarak, buradaki acil yararlar, bu optimizasyon engellerini tamamen yok etmenin yanı sıra, sanal bir işaretçiden daha fazla bir tür tanımlamanın potansiyel olarak daha küçük bir yolu olabilir (örneğin: 256 benzersiz tür veya daha az olduğu fikrini taahhüt ederseniz tek bir bayt). .

switchVeri yapılarınızı ve döngülerinizi alt türe göre ayırmak zorunda kalmadan tek bir merkezi deyim kullanırsanız veya bir sipariş varsa, bazı durumlarda bakımı daha kolay kod yazmanız (yukarıdaki optimize edilmiş manuel devrileştirme örneklerine karşı) yardımcı olabilir. - işlerin kesin bir sırayla işlenmesi gereken bu durumlarda bağımlılık (bu, her yerde dallanmamıza neden olsa bile). Bu, yapılması gereken çok fazla yeriniz olmadığı durumlar içindir switch.

Bu, performansı oldukça kritik bir zihniyet olsa bile, bakımı oldukça kolay olmadıkça bunu tavsiye etmem. "Bakımı kolay" iki baskın faktöre bağlı olma eğilimindedir:

  • Gerçek bir genişletilebilirlik gereksinimine sahip olmama (ör: işlenecek tam olarak 8 türünüz olduğundan emin olmak ve asla daha fazla şey bilmemek ).
  • Kodunuzda bu türleri kontrol etmesi gereken çok yer olmaması (ör: tek bir merkezi yer).

... yine de çoğu durumda yukarıdaki senaryoyu tavsiye ediyorum ve gerektiğinde kısmi yeniden sanallaştırma ile daha verimli çözümlere doğru ilerliyorum. Genişletilebilirlik ve bakım kolaylığı ihtiyaçlarını performansla dengelemek için size çok daha fazla nefes alan sağlar.

Sanal İşlevler ve İşlev İşaretçileri

Bunun üstesinden gelmek için, burada sanal işlevler ve işlev işaretçileri hakkında bazı tartışmaların olduğunu fark ettim. Sanal işlevlerin aramak için biraz ekstra iş gerektirdiği doğrudur, ancak bu daha yavaş oldukları anlamına gelmez. Sezgisel olarak, onları daha da hızlı hale getirebilir.

Burada sezgisel bir durumdur, çünkü bellek hiyerarşisinin çok daha önemli bir etkiye sahip olma dinamiklerine dikkat etmeden talimatları talimat olarak ölçmeye alışkınız.

class20 sanal işlev ile struct20 işlev işaretçisi depolayan ve her ikisi birden çok kez başlatılan bir ile karşılaştırırsak class, bu durumda her bir örneğin bellek yükü 64 bit makinelerde sanal işaretçi için 8 bayt, bellek yükü struct160 bayttır.

Pratik maliyet, sanal işaretler (ve muhtemelen yeterince büyük bir giriş ölçeğinde sayfa hataları) kullanarak işlev işaretçileri tablosu ile sınıfa karşı çok daha zorunlu ve zorunlu olmayan önbellek özlüyor olabilir. Bu maliyet, sanal bir tabloyu dizine ekleme işleminin biraz daha fazla yapılmasını engelleme eğilimindedir.

Ayrıca structs, işlev işaretçileriyle doldurulmuş ve sayısız kez başlatılan eski C kod tabanlarıyla (benden daha eski) uğraştım , aslında sanal işlevlere sahip sınıflara dönüştürerek önemli performans kazançları (% 100'den fazla iyileştirme) verdi ve sadece bellek kullanımındaki büyük azalma, önbellek dostu olma vb.

Flip tarafında, karşılaştırmalar elmalarla elma hakkında daha fazla hale geldiğinde, benzer şekilde, bu tür senaryolarda yararlı olmak için bir C ++ sanal işlev zihniyetinden C stili işlev işaretçi zihniyetine çevirmenin tersi zihniyetini buldum:

class Functionoid
{
public:
    virtual ~Functionoid() {}
    virtual void operator()() = 0;
};

... sınıfın ölçülebilir bir şekilde geçersiz kılınabilen tek bir işlevi (ya da sanal yıkıcıyı saydığımızda ikisini) sakladığı yerdi. Bu durumlarda, kritik yollarda bunu buna dönüştürmek kesinlikle yardımcı olabilir:

void (*func_ptr)(void* instance_data);

... tehlikeli atıkları gizlemek / atmak için güvenli bir arayüzün arkasında ideal bir şekilde void*.

Tek bir sanal işleve sahip bir sınıfı kullanmaya meyilli olduğumuz durumlarda, bunun yerine işlev işaretleyicilerini hızlı bir şekilde kullanmaya yardımcı olabilir. Büyük bir neden, bir işlev işaretçisi çağırmanın maliyetinin azalması bile değildir. Çünkü artık onları kalıcı bir yapıda toplarsak, her bir ayrı fonksiyonoidleri yığının dağınık bölgelerine tahsis etme cazibesiyle karşı karşıya değiliz. Bu tür bir yaklaşım, örnek verileri homojense, örneğin yalnızca davranış değişirse, yığınla ilişkili ve bellek parçalanması yükünü önlemeyi kolaylaştırabilir.

Bu nedenle, işlev işaretçileri kullanmanın yardımcı olabileceği bazı durumlar vardır, ancak genellikle bir grup işlev işaretçisi tablosunu sınıf örneği başına yalnızca bir işaretçinin depolanmasını gerektiren tek bir vtable ile karşılaştırırsak, bunu başka bir şekilde buldum. . Bu vtable genellikle bir veya daha fazla L1 önbellek satırında ve sıkı döngülerde oturacaktır.

Sonuç

Her neyse, bu konudaki benim küçük dönüşüm. Bu alanlara dikkatle girmenizi öneririm. Güven ölçümleri, içgüdüsel değildir ve bu optimizasyonların genellikle sürdürülebilirliği azaltma şekli göz önüne alındığında, yalnızca karşılayabildiğiniz kadar uzağa gidin (ve akıllıca bir yol sürdürülebilirlik tarafında hata yapmak olacaktır).


Sanal işlev, o sınıfın uygulanabilirliğinde uygulanan işlev işaretçileridir. Sanal bir işlev çağrıldığında, önce çocukta ve kalıtım zincirinde yukarı doğru aranır. Bu nedenle derin kalıtım çok pahalıdır ve c ++ 'da genellikle önlenir.
Robert Baron

@RobertBaron: Söylediğiniz gibi sanal işlevlerin uygulandığını hiç görmedim (= sınıf hiyerarşisinde zincirleme arama ile). Genellikle derleyiciler, tüm doğru fonksiyon işaretçileriyle her bir beton tipi için "düzleştirilmiş" bir vtable üretir ve çalışma zamanında çağrı tek bir düz tablo aramasıyla çözülür; derin miras hiyerarşileri için herhangi bir ceza ödenmez.
Matteo Italia

Matteo, teknik bir liderin yıllar önce bana verdiği açıklama buydu. Kabul edildi, c ++ içindi, bu yüzden çoklu kalıtımın sonuçlarını dikkate alıyor olabilir. Vtables'ın nasıl optimize edildiğine ilişkin anlayışımı açıkladığınız için teşekkür ederiz.
Robert Baron

İyi cevap için teşekkürler (+1). Bunun ne kadarının sanal fonksiyonlar yerine std :: visit için de geçerli olduğunu merak ediyorum.
DaveFar

13

Gözlemler:

  • Birçok durumda ile, sanal fonksiyonlar vtable arama bir daha hızlı çünkü vardır O(1)iken operasyon else if()merdiveni bir olduğunu O(n)çalışma. Bununla birlikte, bu sadece davaların dağılımı düzse doğrudur.

  • Tek için if() ... elsesize işlev çağrısı yükü kaydetmek çünkü, koşullu hızlıdır.

  • Dolayısıyla, vakaların düz bir dağılımına sahip olduğunuzda, bir başabaş noktası bulunmalıdır. Tek soru bulunduğu yer.

  • Merdiven veya sanal işlev çağrıları switch()yerine kullanırsanız else if(), derleyiciniz daha da iyi kod üretebilir: tablodan bakılan ancak bir işlev çağrısı olmayan bir konuma şube yapabilir. Yani, tüm işlev çağrısı yükü olmadan sanal işlev çağrısının tüm özelliklerine sahipsiniz.

  • Biri diğerlerinden çok daha sıksa, if() ... elsebu durumla başlamak size en iyi performansı verecektir: Çoğu durumda doğru tahmin edilen tek bir koşullu dal yürütürsünüz.

  • Derleyicinizin vakaların beklenen dağılımı hakkında bilgisi yoktur ve düz bir dağıtım yapar.

Senin derleyici olasılıkla zaman konusunda yerinde bazı iyi sezgisel taramaya sahip beri kod a kadar switch()bir şekilde else if()merdiven veya bir başvuru çizelgesi olarak. Davaların dağıtımının önyargılı olduğunu bilmediğiniz sürece kararına güvenme eğilimindeyim.

Benim tavsiyem şudur:

  • Vakalardan biri geri kalanını frekans açısından cüce ediyorsa, sıralı bir else if()merdiven kullanın .

  • Aksi takdirde switch(), diğer yöntemlerden biri kodunuzu daha okunabilir hale getirmediği sürece bir ifade kullanın . Önemli ölçüde azaltılmış okunabilirlik ile pazarlık edilebilir bir performans kazancı satın almadığınızdan emin olun.

  • A kullandıysanız switch()ve hala performanstan memnun değilseniz, karşılaştırmayı yapın, ancak switch()zaten en hızlı olasılık olduğunu öğrenmeye hazır olun .


2
Bazı derleyiciler ek açıklamaların derleyiciye hangi durumun daha doğru olduğunu söylemesine izin verir ve bu derleyiciler ek açıklama doğru olduğu sürece daha hızlı kod üretebilir.
gnasher729

5
bir O (1) işlemi gerçek dünyadaki yürütme süresinde O (n) ve hatta O (n ^ 20) 'den daha hızlı olmak zorunda değildir.
whatsisname

2
@whatsisname Bu yüzden "birçok dava için" dedim. Tanımı O(1)ve fonksiyonu O(n)vardır kki böylece O(n)fonksiyon O(1)herkes için fonksiyondan daha büyük olur n >= k. Tek soru, bu kadar çok vakanın olup olmayacağınızdır. Ve evet, o switch()kadar çok durumda ifadeler gördüm ki bir else if()merdiven sanal bir işlev çağrısından veya yüklü bir gönderiden kesinlikle daha yavaştır.
cmaster

Bu cevapla ilgili sorunum, tamamen alakasız bir performans kazancına dayalı bir karar vermeye karşı tek uyarı, son paragrafın bir yerinde gizlidir. Her şey burada hakkında bir karar vermek için iyi bir fikir olabilir süsü ifvs switchperfomance dayalı vs sanal fonksiyonlar. Son derece nadir durumlarda olabilir, ancak çoğu durumda değildir.
Doc Brown

7

Genel olarak, dallanmayı önlemek için sanal işlevleri kullanmaya değer mi?

Genel olarak, evet. Bakım için faydalar önemlidir (ayırma, endişelerin ayrılması, modülerlik ve genişletilebilirliğin test edilmesi).

Ancak, genel olarak, sanal işlevlerin dallara karşı ne kadar pahalı olduğu Genelleştirmek için yeterli platformda test etmek zordur, bu yüzden herhangi birinin kaba bir kuralına sahip olup olmadığını merak ediyordum (eğer kırılma noktası 4 if kadar basitse güzel)

Kodunuzu oluşturmadığınız ve şubeler arasındaki gönderimin ( şartların değerlendirilmesi ) gerçekleştirilen hesaplamalardan ( şubelerdeki koddan ) daha fazla zaman almadığı sürece optimize edin.

Yani, "sanal fonksiyonların dallanmaya karşı ne kadar pahalı olduğunu" doğru cevap ölçmek ve bulmaktır.

Temel kural : yukarıdaki durum (şube ayrımcılığı şube hesaplamalarından daha pahalı değilse), kodun bu kısmını bakım çabası için optimize edin (sanal işlevleri kullanın).

Bu bölümün olabildiğince hızlı çalışmasını istediğinizi söylüyorsunuz; Ne kadar hızlı? Somut gereksiniminiz nedir?

Genelde sanal işlevler daha açıktır ve onlara doğru eğilirdim. Ancak, kodu sanal işlevlerden dallara değiştirebileceğim son derece kritik bölümlerim var. Bunu yapmadan önce bu konuda düşüncelerim olmasını tercih ederim. (önemsiz bir değişiklik değildir veya birden fazla platformda test edilmesi kolaydır)

O zaman sanal işlevleri kullanın. Bu, gerekirse platform başına optimizasyon yapmanızı ve yine de istemci kodunu temiz tutmanızı sağlar.


Çok fazla bakım programlama yaptıktan sonra, biraz dikkatli olacağım: sanal işlevler, tam olarak listelediğiniz avantajlar nedeniyle bakım için IMNSHO oldukça kötüdür. Temel sorun esneklikleri; oraya hemen hemen her şeyi yapıştırabilirsin ... ve insanlar yapar. Dinamik dağıtım hakkında statik olarak mantık yürütmek çok zordur. Bununla birlikte, çoğu özel durumda kodun tüm bu esnekliğe ihtiyacı yoktur ve çalışma zamanı esnekliğinin kaldırılması , kod hakkında akıl yürütmeyi kolaylaştırabilir. Yine de asla dinamik gönderimi kullanmamanız gerektiğini söyleyecek kadar ileri gitmek istemiyorum; bu çok saçma.
Eamon Nerbonne

Çalışmak için en güzel soyutlamalar nadir olanlardır (yani bir kod tabanında sadece birkaç opak soyutlama vardır), ancak süper körük sağlamdır. Temel olarak: sadece belirli bir durum için benzer bir şekle sahip olduğu için dinamik bir gönderim soyutlamasının arkasına bir şey yapıştırmayın; bunu yalnızca o arayüzü paylaşan nesneler arasındaki herhangi bir ayrımı önemsemek için makul bir neden düşünemiyorsanız yapın . Yapamazsanız: sızdıran bir soyutlamadan daha kapsülleyici olmayan bir yardımcıya sahip olmak daha iyidir. Ve o zaman bile; çalışma zamanı esnekliği ile kod tabanı esnekliği arasında bir denge vardır.
Eamon Nerbonne

5

Diğer cevaplar zaten iyi teorik argümanlar sunuyor. Yakın zamanda gerçekleştirdiğim bir deneyin sonuçlarını eklemek için switch, op-kodu üzerinde büyük bir sanal makine (VM) uygulamak ya da op-kodu bir dizin olarak yorumlamak iyi bir fikir olup olmadığını tahmin etmek istiyorum işlev işaretçileri dizisine dönüştürür. Bu bir virtualişlev çağrısı ile tam olarak aynı olmasa da, oldukça yakın olduğunu düşünüyorum.

1 ile 10000 arasında rasgele seçilmiş bir komut kümesi boyutu (eşit olmasa da, düşük aralığı daha yoğun örnekleme) ile bir VM için C ++ 14 kodu oluşturmak için bir Python komut dosyası yazdım. Oluşturulan VM her zaman 128 kayıt vardı ve hayır VERİ DEPOSU. Talimatlar anlamlı değildir ve hepsi aşağıdaki forma sahiptir.

inline void
op0004(machine_state& state) noexcept
{
  const auto c = word_t {0xcf2802e8d0baca1dUL};
  const auto r1 = state.registers[58];
  const auto r2 = state.registers[69];
  const auto r3 = ((r1 + c) | r2);
  state.registers[6] = r3;
}

Komut dosyası ayrıca bir switchdeyim kullanarak gönderme rutinleri oluşturur …

inline int
dispatch(machine_state& state, const opcode_t opcode) noexcept
{
  switch (opcode)
  {
  case 0x0000: op0000(state); return 0;
  case 0x0001: op0001(state); return 0;
  // ...
  case 0x247a: op247a(state); return 0;
  case 0x247b: op247b(state); return 0;
  default:
    return -1;  // invalid opcode
  }
}

… Ve bir dizi fonksiyon işaretçisi.

inline int
dispatch(machine_state& state, const opcode_t opcode) noexcept
{
  typedef void (* func_type)(machine_state&);
  static const func_type table[VM_NUM_INSTRUCTIONS] = {
    op0000,
    op0001,
    // ...
    op247a,
    op247b,
  };
  if (opcode >= VM_NUM_INSTRUCTIONS)
    return -1;  // invalid opcode
  table[opcode](state);
  return 0;
}

Hangi dağıtım rutininin üretildiği, oluşturulan her VM için rastgele seçilmiştir.

Kıyaslama için, op-kod akışı rastgele tohumlanmış ( std::random_device) Mersenne twister rastgele motor ( std::mt19937_64) tarafından üretilmiştir .

Her VM için kod -DNDEBUG, -O3ve -std=c++14anahtarları kullanılarak GCC 5.2.0 ile derlenmiştir . İlk olarak, -fprofile-generate1000 rastgele talimatın simülasyonu için toplanan seçenek ve profil verileri kullanılarak derlenmiştir . Kod daha sonra -fprofile-usetoplanan profil verilerine dayalı optimizasyonlara izin veren seçenekle yeniden derlendi .

Daha sonra VM, 50.000 000 döngü için dört kez (aynı işlemde) uygulandı ve her çalışma için süre ölçüldü. İlk çalıştırma, soğuk önbellek etkilerini ortadan kaldırmak için atıldı. PRNG, aynı talimat dizisini gerçekleştirmemek için çalışmalar arasında yeniden tohumlanmadı.

Bu kurulum kullanılarak, her sevk rutini için 1000 veri noktası toplanmıştır. Veriler dört çekirdekli AMD A8-6600K APU'da 2048 KiB önbellek ile 64 bit GNU / Linux çalışan bir grafik masaüstü veya başka programlar olmadan toplandı. Aşağıda her VM için talimat başına ortalama CPU süresinin (standart sapma ile) bir grafiği verilmiştir.

resim açıklamasını buraya girin

Bu verilerden, çok az sayıda op kodu dışında bir işlev tablosu kullanmanın iyi bir fikir olduğuna güvenebilirim. Benim aykırı değerleri için bir açıklamam yokswitch500 ve 1000 talimatlar arasında sürümün .

Kıyaslama için tüm kaynak kodu, tam deneysel veriler ve yüksek çözünürlüklü bir çizim web sitemde bulunabilir .


3

Cmaster'ın benim önerdiğim iyi cevabına ek olarak, işlev işaretleyicilerinin genellikle sanal işlevlerden kesinlikle daha hızlı olduğunu unutmayın. Sanal işlevler dağıtımı genellikle önce nesneden vtable'a bir işaretçi izlemeyi, uygun şekilde indekslemeyi ve daha sonra bir işlev işaretçisinin kaydının kaldırılmasını içerir. Yani son adım aynı, ancak başlangıçta ekstra adımlar var. Ek olarak, sanal işlevler her zaman bir argüman olarak "this" i alır, işlev işaretçileri daha esnektir.

Akılda tutulması gereken başka bir şey: kritik yolunuz bir döngü içeriyorsa, döngüyü gönderim hedefine göre sıralamak yardımcı olabilir. Açıkçası bu nlogn, döngüden geçmek sadece n'dir, ancak birçok kez geçecekseniz buna değebilir. Dağıtım hedefine göre sıralayarak, aynı kodun tekrar tekrar yürütüldüğünden emin olursunuz, bu kodu icache'de sıcak tutar, önbellek hatalarını en aza indirir.

Akılda tutulması gereken üçüncü bir strateji: sanal işlevlerden / işlev işaretçilerinden if / switch stratejilerine doğru hareket etmeye karar verirseniz, polimorfik nesnelerden boost :: variant (ayrıca anahtarı da sağlar) ziyaretçi soyutlama şeklinde). Polimorfik nesnelerin temel işaretçi tarafından saklanması gerekir, böylece verileriniz önbellekteki her yerde bulunur. Bu, kritik yolunuz üzerinde sanal aramanın maliyetinden daha büyük bir etki olabilir. Varyant satır içi olarak ayrımcı bir birlik olarak depolanırken; en büyük veri türüne eşit bir boyuta sahiptir (artı küçük bir sabit). Nesnelerinizin boyutu çok fazla farklılık göstermiyorsa, bu onları işlemek için harika bir yoldur.

Aslında, verilerinizin önbellek tutarlılığını iyileştirmek orijinal sorunuzdan daha büyük bir etkiye sahip olursa şaşırmam, bu yüzden kesinlikle buna daha fazla bakarım.


Bir sanal fonksiyonun "ekstra adımlar" içerdiğini bilmiyorum. Sınıf düzeninin derleme zamanında bilindiği göz önüne alındığında, temel olarak bir dizi erişimi ile aynıdır. Sınıfın üstünde bir işaretçi vardır ve işlevin ofseti bilinir, bu yüzden sadece ekleyin, sonucu okuyun ve adres budur. Fazla yük değil.

1
Fazladan adımlar içerir. Vtable'ın kendisi işlev işaretçileri içerir, bu yüzden onu vtable'a yaptığınızda, bir işlev işaretçisi ile başladığınız duruma ulaşmış olursunuz. Vtable'a ulaşmadan önce her şey ekstra iştir. Sınıflar vtable'larını içermez, vtables için işaretçiler içerir ve bu işaretçiyi takip etmek ekstra bir dereference. Aslında, polimorfik sınıflar genellikle temel sınıf işaretçisi tarafından tutulduğu için bazen üçüncü bir dereference vardır, bu nedenle vtable adresini almak için bir işaretçi serbest bırakmanız gerekir (dereference ;-)).
Nir Friedman

Kapak tarafında, vtable'ın örneğin dışında saklanması gerçeği, örneğin her bir işlev işaretçisinin farklı bir bellek adresinde saklandığı işlev işaretçilerinden oluşan bir grup ayrı yapı yapısına karşı geçici yerellik için gerçekten yararlı olabilir. Bu gibi durumlarda, bir milyon vptr değerine sahip tek bir vtable, bir milyon işlev işaretçisi tablosunu kolayca yenebilir (sadece bellek tüketiminden başlayarak). Burada bir parça atma olabilir - yıkmak o kadar kolay değil. Genellikle işlev işaretçisinin biraz daha ucuz olduğunu kabul ediyorum, ancak birini diğerinin üzerine koymak o kadar kolay değil.

Sanırım başka bir yol koymak, burada sanal işlevler hızlı ve kaba bir şekilde işlev işaretçileri daha iyi performans başlar nerede dahil nesne örnekleri bir tekne yükü (her nesnenin birden çok işlev işaretçisi veya tek bir vptr depolamak gerekir) olduğunu. İşlevsel işaretçiler, örneğin, bellekte depolanan tek bir işlev işaretçisine sahipseniz, tekne yükü olarak adlandırılacak zaman daha ucuz olma eğilimindedir. Aksi takdirde, işlev işaretçileri veri yedekliliği miktarıyla yavaşlamaya başlayabilir ve birçok gereksiz bellekten ve aynı adrese işaret eden önbellek kayıplarından kaynaklanabilir.

Tabii ki fonksiyon işaretçileriyle, bellek biriktirmekten ve önbellek özlemlerini kaçırmamak için milyonlarca ayrı nesne tarafından paylaşılsalar bile onları merkezi bir yerde saklayabilirsiniz. Ancak daha sonra aramak istediğimiz gerçek işlev adreslerine ulaşmak için bellekte paylaşılan bir konuma işaretçi erişimi içeren vpoint'lere eşdeğer olmaya başlarlar. Buradaki temel soru şudur: işlev adresini şu anda erişmekte olduğunuz verilere daha yakın mı yoksa merkezi bir yerde mi saklıyorsunuz? vtables sadece ikincisine izin verir. İşlev işaretçileri her iki yöne de izin verir.

2

Bunun neden bir XY sorunu olduğunu düşündüğümü açıklayabilir miyim ? (Onlara sormakta yalnız değilsin.)

Gerçek hedefinizin, sadece önbellek özümleri ve sanal işlevlerle ilgili bir noktayı anlamak için değil, genel olarak zamandan tasarruf etmek olduğunu varsayıyorum .

İşte gerçek yazılımda gerçek performans ayarı örneği .

Gerçek yazılımda, programcı ne kadar deneyimli olursa olsun, daha iyi yapılabilecek işler yapılır. Program yazılana ve performans ayarlaması yapılana kadar ne olduklarını bilmiyoruz. Programı hızlandırmanın neredeyse her zaman birden fazla yolu vardır. Sonuçta, bir programın optimal olduğunu söylemek, probleminizi çözmek için olası programların pantheonunda, hiçbirinin daha az zaman almadığını söylüyorsunuz. Gerçekten mi?

Bağlantı verdiğim örnekte, başlangıçta "iş" başına 2700 mikrosaniye aldı. Altı problemden oluşan bir dizi düzeltildi ve pizza etrafında saat yönünün tersine gitti. İlk hızlanma% 33 oranında kaldı. İkincisi% 11'i çıkardı. Ancak dikkat edin, ikincisi bulunduğunda% 11 değildi,% 16 idi, çünkü ilk sorun ortadan kalktı . Benzer şekilde, üçüncü sorun% 7.4'ten% 13'e (neredeyse iki katına) büyüdü, çünkü ilk iki sorun ortadan kalktı.

Sonunda, bu büyütme işlemi 3.7 mikrosaniye dışındaki her şeyin ortadan kaldırılmasına izin verdi. Bu, orijinal sürenin% 0.14'ü veya 730x'lik bir hızlanma.

resim açıklamasını buraya girin

Başlangıçta büyük sorunların giderilmesi orta düzeyde bir hızlanma sağlar, ancak daha sonraki sorunların giderilmesine yol açar. Daha sonraki bu sorunlar başlangıçta toplamın önemsiz parçaları olabilir, ancak erken sorunlar giderildikten sonra bu küçük sorunlar büyür ve büyük hızlanmalara neden olabilir. (Bu sonucu elde etmek için hiçbirinin kaçırılmayacağını ve bu yayının ne kadar kolay olabileceğini gösterdiğini anlamak önemlidir.)

resim açıklamasını buraya girin

Son program uygun muydu? Muhtemelen değil. Hızlandırmaların hiçbirinin önbellek özlemleriyle ilgisi yoktu. Önbellek özledikleri artık önemli miydi? Olabilir.

EDIT: OP sorununun "son derece kritik bölümleri" üzerinde homing insanlardan aşağı oy alıyorum. Zamanın hangi kısmını oluşturduğunu bilinceye kadar bir şeyin "son derece kritik" olduğunu bilmiyorsunuz. Çağrılan bu yöntemlerin ortalama maliyeti 10 döngü veya daha fazla ise, zaman içinde onlara gönderme yöntemi, gerçekte yaptıklarına kıyasla muhtemelen "kritik" değildir. Bunu defalarca görüyorum, insanlar "her nanosaniyeye ihtiyaç duyuyor" u kuruş bilge ve pound-aptal olmak için bir neden olarak görüyorlar.


zaten her nanosaniyede bir performans gerektiren çok sayıda kritik bölüme sahip olduğunu söyledi. Bu, sorduğu soruya bir cevap değil (başka birinin sorusuna harika bir cevap olsa bile)
gbjbaanb

2
@gbjbaanb: Son nanosaniyelerin her biri önemliyse, soru neden "genel olarak" ile başlıyor? Bu saçma. Nanosaniye sayıldığında, genel cevapları arayamazsınız, derleyicinin ne yaptığına bakarsınız, donanımın ne yaptığına bakarsınız, varyasyonları denersiniz ve her varyasyonu ölçersiniz.
gnasher729

@ gnasher729 Bilmiyorum, ama neden "son derece kritik bölümler" ile bitiyor? Sanırım, slashdot gibi, sadece başlık değil, her zaman içeriği okumalı!
gbjbaanb

2
@gbjbaanb: Herkes "son derece kritik bölümlere" sahip olduklarını söylüyor. Nereden biliyorlar? 10 örnek alıp 2 ya da daha fazlasında görene kadar bir şeyin kritik olduğunu bilmiyorum. Böyle bir durumda, çağrılan yöntemler 10'dan fazla talimat alırsa, sanal işlev yükü muhtemelen önemsizdir.
Mike Dunlavey

@ gnasher729: Yaptığım ilk şey yığın örnekleri almak ve her birinde programın ne yaptığını ve nedenini incelemek. Sonra tüm zamanını çağrı ağacının yapraklarında geçiriyorsa ve tüm çağrılar gerçekten kaçınılmazsa , derleyici ve donanımın ne yaptığı önemli midir? Yöntem dağıtımı yalnızca, numuneler yöntem dağıtımı yapma sürecine gelirse önemlidir.
Mike Dunlavey
Sitemizi kullandığınızda şunları okuyup anladığınızı kabul etmiş olursunuz: Çerez Politikası ve Gizlilik Politikası.
Licensed under cc by-sa 3.0 with attribution required.