c++ виртуална функция срещу указател на членска функция (сравнение на производителността)

Извикванията на виртуални функции могат да бъдат бавни поради виртуалните извиквания, изискващи допълнително индексирано уважение към v-таблицата, което може да доведе до пропуск на кеш данни, както и пропуск на кеш на инструкции... Не е добре за критични за производителността приложения.

Така че мислех за начин да преодолея този проблем с производителността на виртуалните функции, но въпреки това да имам част от същата функционалност, която предоставят виртуалните функции.

Убеден съм, че това е правено и преди, но създадох прост тест, който позволява на базовия клас да съхранява указател на членска функция, който може да бъде зададен от всеки производен клас. И когато извикам Foo() на който и да е производен клас, той ще извика подходящата членска функция, без да се налага да преминава през v-таблицата...

Просто се чудя дали този метод е жизнеспособен заместител на парадигмата на виртуалното обаждане, ако е така, защо не е по-всеместно разпространен?

Благодаря предварително за отделеното време! :)

class BaseClass
{
protected:

    // member function pointer
    typedef void(BaseClass::*FooMemFuncPtr)();
    FooMemFuncPtr m_memfn_ptr_Foo;

    void FooBaseClass() 
    {
        printf("FooBaseClass() \n");
    }

public:

    BaseClass()
    {
        m_memfn_ptr_Foo = &BaseClass::FooBaseClass;
    }

    void Foo()
    {
        ((*this).*m_memfn_ptr_Foo)();
    }
};

class DerivedClass : public BaseClass
{
protected:

    void FooDeriveddClass()
    {
        printf("FooDeriveddClass() \n");
    }

public:

    DerivedClass() : BaseClass()
    {
        m_memfn_ptr_Foo = (FooMemFuncPtr)&DerivedClass::FooDeriveddClass;
    }
};

int main(int argc, _TCHAR* argv[])
{
    DerivedClass derived_inst;
    derived_inst.Foo(); // "FooDeriveddClass()"

    BaseClass base_inst;
    base_inst.Foo(); // "FooBaseClass()"

    BaseClass * derived_heap_inst = new DerivedClass;
    derived_heap_inst->Foo();

    return 0;
}

person eddietree    schedule 27.06.2013    source източник
comment
1. Моля, профилирайте кода, преди да задавате въпроси като тези. Това, което всъщност казвате, е профилиране на кода за мен. 2. Потърсете полиморфизъм по време на компилиране.   -  person Luchian Grigore    schedule 27.06.2013
comment
Старо, но може да е интересно за вас: codeproject. com/Articles/7150/   -  person PlasmaHH    schedule 27.06.2013
comment
да, планирам да профилирам кода, но ми беше любопитно дали има концептуални разлики в производителността   -  person eddietree    schedule 27.06.2013
comment
защо не е по-вездесъщ? По същата причина, поради която асемблерният език и Brainf*** не са повсеместни ... о, и е по-бавен.   -  person Jim Balter    schedule 27.06.2013
comment
иска ли да обясни защо е по-бавно?   -  person eddietree    schedule 27.06.2013
comment
Вие плащате за (потенциалното) спестяване на един пропуск на кеша, като съхранявате един функционален указател на обект вместо един на клас и загубата на неизменност (т.е. предвидимост) на функционалния указател. Сигурен съм, че този компромис е измерван многократно, за да се стигне до общата реализация на виртуални разговори. Като алтернатива, всички внедрители на C++ са глупаци, които просто не са се сетили да го направят по този начин, но някак си се съмнявам в това.   -  person molbdnilo    schedule 27.06.2013
comment
Причината за съществуването на виртуални функции е да се позволи решението какъв код да се изпълни да бъде взето възможно най-късно, въз основа на типа обект. Това е модулна алтернатива на оператор switch или if-ladder. Ако това е, от което се нуждае вашата програма, тогава я използвайте. Ако не, недей.   -  person Mike Dunlavey    schedule 27.06.2013


Отговори (5)


Направих тест и версията, използваща извиквания на виртуални функции, беше по-бърза в моята система с оптимизация.

$ time ./main 1
Using member pointer

real    0m3.343s
user    0m3.340s
sys     0m0.002s

$ time ./main 2
Using virtual function call

real    0m2.227s
user    0m2.219s
sys     0m0.006s

Ето кода:

#include <cstdlib>
#include <cstring>
#include <iostream>
#include <stdio.h>

struct BaseClass
{
    typedef void(BaseClass::*FooMemFuncPtr)();
    FooMemFuncPtr m_memfn_ptr_Foo;

    void FooBaseClass() { }

    BaseClass()
    {
        m_memfn_ptr_Foo = &BaseClass::FooBaseClass;
    }

    void Foo()
    {
        ((*this).*m_memfn_ptr_Foo)();
    }
};

struct DerivedClass : public BaseClass
{
    void FooDerivedClass() { }

    DerivedClass() : BaseClass()
    {
        m_memfn_ptr_Foo = (FooMemFuncPtr)&DerivedClass::FooDerivedClass;
    }
};

struct VBaseClass {
  virtual void Foo() = 0;
};

struct VDerivedClass : VBaseClass {
  virtual void Foo() { }
};

static const size_t count = 1000000000;

static void f1(BaseClass* bp)
{
  for (size_t i=0; i!=count; ++i) {
    bp->Foo();
  }
}

static void f2(VBaseClass* bp)
{
  for (size_t i=0; i!=count; ++i) {
    bp->Foo();
  }
}

int main(int argc, char** argv)
{
    int test = atoi(argv[1]);
    switch (test) {
        case 1:
        {
            std::cerr << "Using member pointer\n";
            DerivedClass d;
            f1(&d);
            break;
        }
        case 2:
        {
            std::cerr << "Using virtual function call\n";
            VDerivedClass d;
            f2(&d);
            break;
        }
    }

    return 0;
}

Компилиран с помощта на:

g++ -O2    main.cpp   -o main

с g++ 4.7.2.

person Vaughn Cato    schedule 27.06.2013
comment
много интересно.. много благодаря за профилирането! чудя се колко общо има това с това, че vtable и инструкциите са свежи в кеша - person eddietree; 27.06.2013
comment
Това може да се дължи и на факта, че виртуалните таблици са били в C++ от дълго време и следователно авторите на компилатори са се научили как и кога да ги оптимизират. Както при вашия код, компилаторът може да направи по-малко предположения и трябва да направи по-малко оптималното нещо. - person Daemin; 27.06.2013

Извикванията на виртуални функции могат да бъдат бавни поради това, че виртуалните извиквания трябва да преминат през v-таблицата,

Това не е съвсем правилно. Vtable трябва да се изчисли при конструиране на обект, като всеки указател на виртуална функция е зададен на най-специализираната версия в йерархията. Процесът на извикване на виртуална функция не итерира указатели, а извиква нещо като *(vtbl_address + 8)(args);, което се изчислява в постоянно време.

което може да доведе до пропуск в кеша за данни, както и до пропуск в кеша на инструкции... Не е добре за критични за производителността приложения.

Вашето решение също не е добро за критични за производителността приложения (като цяло), защото е общо.

По правило критичните за производителността приложения се оптимизират за всеки отделен случай (измерете, изберете код с най-лоши проблеми с производителността в модула и оптимизирайте).

С този подход за всеки случай вероятно никога няма да имате случай, в който вашият код е бавен, защото компилаторът трябва да премине през vtbl. Ако случаят е такъв, забавянето вероятно ще дойде от извикване на функции чрез указатели вместо директно (т.е. проблемът ще бъде решен чрез вграждане, а не чрез добавяне на допълнителен указател в основния клас).

Всичко това така или иначе е академично, докато нямате конкретен случай за оптимизиране (и сте измерили, че най-лошият ви нарушител са извикванията на виртуални функции).

Редактиране:

Просто се чудя дали този метод е жизнеспособен заместител на парадигмата на виртуалното обаждане, ако е така, защо не е по-всеместно разпространен?

Тъй като изглежда като общо решение (повсеместното му прилагане би намалило производителността, вместо да я подобри), решавайки несъществуващ проблем (приложението ви обикновено не се забавя поради извиквания на виртуални функции).

person utnapistim    schedule 27.06.2013

Виртуалните функции не "обикалят" таблицата, просто правят едно извличане на указател от местоположение и извикват този адрес. Това сякаш сте имали ръчно внедряване на указател към функция и сте го използвали за повикване вместо директно.

Така че вашата работа е добра само за обфускация и саботира случаите, когато компилаторът може да издаде невиртуално директно извикване.

Използването на функция указател към член вероятно е дори по-лошо от PTF, вероятно ще използва същата VMT структура за подобен изместен достъп, само променлив, вместо фиксиран.

person Balog Pal    schedule 27.06.2013
comment
Те трябва допълнително да извлекат vptr, което може или не може да доведе до зареждане на същата кеш линия, в зависимост от това какво прави функцията. - person PlasmaHH; 27.06.2013
comment
вярно, но кешовете са големи и кодът е малък... превключването на контекст наистина е по-голяма заплаха от v-таблица. - person Mgetz; 27.06.2013
comment
Не е много възможно да се спекулира за пропуски в кеша, но измервайте. Но представената алтернатива изглежда има поне същото количество косвени указания... - person Balog Pal; 27.06.2013
comment
@PlasmaHH: вижте действителните измервания в друг отговор от VC - person Balog Pal; 27.06.2013

Най-вече защото не работи. Повечето съвременни процесори са по-добри в предвиждането на разклоненията и спекулативното изпълнение, отколкото си мислите. Въпреки това все още не съм виждал CPU, който извършва спекулативно изпълнение отвъд нестатичен клон.

Освен това в модерен процесор е по-вероятно да имате пропуск в кеша, защото сте имали превключване на контекст точно преди повикването и друга програма е поела кеша, отколкото вие поради v-таблица, дори този сценарий е много далечна възможност .

person Mgetz    schedule 27.06.2013
comment
Благодаря ви много за отговора, но можете ли да обясните какво имате предвид, че не работи и как предвиждането на разклонения влиза в действие за моя конкретен пример? - person eddietree; 27.06.2013
comment
По принцип това е предварителна оптимизация, която се изпреварва, но фактът, че изпълнявате кода на модерна некооперативна многозадачна операционна система. Освен това TLB е наистина добър в предсказването на това какво ще се използва след това и тъй като функциите във vTable са склонни да бъдат в една и съща кодова страница, те почти винаги ще бъдат в кеша. - person Mgetz; 27.06.2013

Всъщност някои компилатори може да използват thunks, които се превеждат в самите обикновени указатели на функции, така че основно компилаторът прави за вас това, което се опитвате да направите ръчно (и вероятно обърква хората).

Освен това, имайки указател към виртуална функционална таблица, пространствената сложност на виртуалната функция е O(1) (само указателят). От друга страна, ако съхранявате указатели на функции в класа, тогава сложността е O(N) (вашият клас вече съдържа толкова указатели, колкото има "виртуални" функции). Ако има много функции, вие плащате такса за това - когато предварително извличате вашия обект, вие зареждате всички указатели в реда на кеша, вместо само един указател и първите няколко членове, които вероятно ще ви трябват. Това звучи като загуба.

Таблицата с виртуални функции, от друга страна, седи на едно място за всички обекти от един тип и вероятно никога не се изтласква от кеша, докато вашият код извиква някои кратки виртуални функции в цикъл (което вероятно е проблемът, когато виртуалната функция цената ще се превърне в пречка).

Що се отнася до предвиждането на разклоненията, в някои случаи простото дърво на решенията върху типа обект и вградените функции за всеки конкретен тип дават добра производителност (тогава съхранявате информация за типа вместо указател). Това не е приложимо за всички видове проблеми и би било най-вече преждевременна оптимизация.

Като основно правило не се притеснявайте за езиковите конструкции, защото изглеждат непознати. Тревожете се и оптимизирайте само след като сте измерили и определили къде наистина е тясното място.

person the swine    schedule 18.02.2015