Виртуальная функция С++ против указателя функции-члена (сравнение производительности)

Вызовы виртуальных функций могут быть медленными из-за того, что виртуальные вызовы требуют дополнительного индексированного уважения к 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. ком/Статьи/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. Если это то, что нужно вашей программе, используйте ее. Если нет, то не надо.   -  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

с г++ 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-таблицу,

Это не совсем правильно. Виртуальную таблицу следует вычислять при построении объекта, при этом каждый указатель виртуальной функции должен быть установлен на наиболее специализированную версию в иерархии. Процесс вызова виртуальной функции не перебирает указатели, а вызывает что-то вроде *(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

В основном потому, что это не работает. Большинство современных процессоров лучше умеют прогнозировать переходы и спекулятивное выполнение, чем вы думаете. Однако мне еще предстоит увидеть процессор, который выполняет спекулятивное выполнение за пределами нестатической ветки.

Кроме того, в современном процессоре у вас больше шансов получить промах кеша, потому что у вас было переключение контекста непосредственно перед вызовом, и другая программа заняла кеш, чем вы из-за v-таблицы, даже этот сценарий очень маловероятен. .

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

На самом деле некоторые компиляторы могут использовать преобразователи, которые сами преобразуются в обычные указатели на функции, так что в основном компилятор делает за вас то, что вы пытаетесь сделать вручную (и, вероятно, сбивает людей с толку).

Кроме того, имея указатель на таблицу виртуальных функций, пространственная сложность виртуальной функции составляет O (1) (только указатель). С другой стороны, если вы храните указатели на функции внутри класса, тогда сложность будет O(N) (теперь ваш класс содержит столько указателей, сколько «виртуальных» функций). Если есть много функций, вы платите за это - при предварительной выборке вашего объекта вы загружаете все указатели в строке кэша, а не только один указатель и первые несколько членов, которые вам могут понадобиться. Это звучит как пустая трата времени.

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

Что касается предсказания ветвления, то в некоторых случаях простое дерево решений по типу объекта и встроенные функции для каждого конкретного типа дают хорошую производительность (тогда вы сохраняете информацию о типе вместо указателя). Это применимо не ко всем типам проблем и в большинстве случаев было бы преждевременной оптимизацией.

Как правило, не беспокойтесь о языковых конструкциях, потому что они кажутся вам незнакомыми. Беспокойтесь и оптимизируйте только после того, как вы измерите и определите, где действительно находится узкое место.

person the swine    schedule 18.02.2015