Виртуальные функции и производительность - C++

В своем дизайне классов я широко использую абстрактные классы и виртуальные функции. У меня было ощущение, что виртуальные функции влияют на производительность. Это правда? Но я думаю, что эта разница в производительности не заметна и, похоже, я делаю преждевременную оптимизацию. Правильно?

15 ответов

Решение

Хорошее эмпирическое правило:

Это не проблема производительности, пока вы не сможете доказать это.

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

Отличная статья, рассказывающая о виртуальных функциях (и не только), - это указатели на функции-члены и максимально быстрые делегаты C++.

Ваш вопрос вызвал у меня любопытство, поэтому я выбрал тактирование для процессора PowerPC 3 ГГц, с которым мы работаем. Тест, который я провел, состоял в том, чтобы создать простой 4d векторный класс с функциями get/set

class TestVec 
{
    float x,y,z,w; 
public:
    float GetX() { return x; }
    float SetX(float to) { return x=to; }  // and so on for the other three 
}

Затем я установил три массива, каждый из которых содержал 1024 таких вектора (достаточно мал, чтобы уместиться в L1), и запустил цикл, который добавил их друг к другу (Ax = Bx + Cx) 1000 раз. Я запустил это с функциями, определенными как inline, virtualи регулярные вызовы функций. Вот результаты:

  • встроенный: 8 мс (0,65 нс на звонок)
  • прямой: 68 мс (5,53 нс на звонок)
  • виртуальный: 160 мс (13 нс на звонок)

Таким образом, в этом случае (где все умещается в кеше) вызовы виртуальных функций были примерно в 20 раз медленнее, чем встроенные вызовы. Но что это на самом деле означает? Каждое путешествие по петле вызывало ровно 3 * 4 * 1024 = 12,288 вызовы функций (1024 вектора, умноженные на четыре компонента, умноженные на три вызова на сложение), поэтому эти времена представляют 1000 * 12,288 = 12,288,000 вызовы функций. Виртуальный цикл занимал на 92 мс дольше, чем прямой цикл, поэтому дополнительные издержки на вызов составляли 7 наносекунд на функцию.

Из этого я делаю вывод: да, виртуальные функции намного медленнее, чем прямые, и нет, если вы не планируете вызывать их десять миллионов раз в секунду, это не имеет значения.

Смотрите также: сравнение сгенерированной сборки.

Когда Objective-C (где все методы являются виртуальными) является основным языком для iPhone, а freakin ' Java - основным языком для Android, я думаю, что довольно безопасно использовать виртуальные функции C++ на наших двухъядерных башнях 3 ГГц.

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

При обычном вызове функции может возникнуть ошибка кэша команд, когда ЦП получает первую инструкцию новой функции, а ее нет в кэше.

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

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

Со страницы 44 руководства Агнера Фога "Оптимизация программного обеспечения на C++":

Время, необходимое для вызова виртуальной функции-члена, на несколько тактов больше, чем для вызова не виртуальной функции-члена, при условии, что оператор вызова функции всегда вызывает одну и ту же версию виртуальной функции. Если версия изменится, вы получите штраф за неправильное предсказание в 10 - 30 тактов. Правила прогнозирования и неправильного предсказания вызовов виртуальных функций такие же, как и для операторов switch...

Абсолютно. Когда компьютеры работали на частоте 100 МГц, это было проблемой, так как каждый вызов метода требовал поиска в виртуальной таблице перед ее вызовом. Но сегодня.. на процессоре 3 ГГц, который имеет кэш 1-го уровня с большим объемом памяти, чем у моего первого компьютера? Не за что. Выделение памяти из основной оперативной памяти будет стоить вам больше времени, чем если бы все ваши функции были виртуальными.

Это как в старые, старые времена, когда люди говорили, что структурированное программирование медленное, потому что весь код разделен на функции, каждая функция требует выделения стека и вызова функции!

Единственный раз, когда я бы даже подумал о том, чтобы рассмотреть влияние виртуальной функции на производительность, это если бы она очень интенсивно использовалась и создавалась в шаблонном коде, который заканчивался во всем. Даже тогда я не стал бы тратить на это слишком много усилий!

PS подумайте о других "простых в использовании" языках - все их методы виртуальны под прикрытием, и в настоящее время они не сканируются.

Есть и другие критерии производительности, кроме времени исполнения. Vtable также занимает место в памяти, и в некоторых случаях этого можно избежать: ATL использует " симулированное динамическое связывание" во время компиляции с шаблонами, чтобы получить эффект "статического полиморфизма", что довольно сложно объяснить; вы в основном передаете производный класс в качестве параметра в шаблон базового класса, поэтому во время компиляции базовый класс "знает", каков его производный класс в каждом экземпляре. Мы не позволим вам хранить несколько различных производных классов в коллекции базовых типов (это полиморфизм во время выполнения), но в статическом смысле, если вы хотите создать класс Y, который совпадает с классом шаблона X, существовавшим ранее, который имеет перехватывает для этого вида переопределения, вам просто нужно переопределить методы, которые вам нужны, и тогда вы получите базовые методы класса X без необходимости иметь vtable.

В классах с большими объемами памяти стоимость одного указателя на vtable невелика, но некоторые из классов ATL в COM очень малы, и это стоит экономии vtable, если случай полиморфизма во время выполнения никогда не произойдет.

Смотрите также этот другой вопрос ТАК.

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

Да, вы правы, и если вам интересно узнать стоимость виртуального вызова функции, вам может показаться интересным этот пост.

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

Хотя, как говорили другие люди, в реальной жизни это никогда не станет для вас проблемой. И если вы думаете, что это так, запустите профилировщик, проведите несколько тестов и проверьте, действительно ли это проблема, прежде чем пытаться "отменить" код для повышения производительности.

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

Это хорошо видно из теста, разница во времени ~700% (!):

#include <time.h>

class Direct
{
public:
    int Perform(int &ia) { return ++ia; }
};

class AbstrBase
{
public:
    virtual int Perform(int &ia)=0;
};

class Derived: public AbstrBase
{
public:
    virtual int Perform(int &ia) { return ++ia; }
};


int main(int argc, char* argv[])
{
    Direct *pdir, dir;
    pdir = &dir;

    int ia=0;
    double start = clock();
    while( pdir->Perform(ia) );
    double end = clock();
    printf( "Direct %.3f, ia=%d\n", (end-start)/CLOCKS_PER_SEC, ia );

    Derived drv;
    AbstrBase *ab = &drv;

    ia=0;
    start = clock();
    while( ab->Perform(ia) );
    end = clock();
    printf( "Virtual: %.3f, ia=%d\n", (end-start)/CLOCKS_PER_SEC, ia );

    return 0;
}

Влияние вызова виртуальной функции сильно зависит от ситуации. Если внутри функции мало вызовов и значительный объем работы - это может быть незначительным.

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

Стоит отметить, что это:

boolean contains(A element) {
    for (A current: this)
        if (element.equals(current))
            return true;
    return false;
}

может быть быстрее, чем это:

boolean contains(A element) {
    for (A current: this)
        if (current.equals(equals))
            return true;
    return false;
}

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

Я говорю "может", потому что это зависит от компилятора, кэша и т. Д.

По моему опыту, главное - это способность встроить функцию. Если у вас есть потребность в производительности / оптимизации, которая требует указания функции, то вы не можете сделать функцию виртуальной, потому что это предотвратит это. В противном случае вы, вероятно, не заметите разницу.

Я делал это по крайней мере 20 раз в моем конкретном проекте. Хотя с точки зрения повторного использования кода, ясности, удобства сопровождения и читабельности могут быть достигнуты значительные успехи, с другой стороны, с виртуальными функциями все еще существуют потери производительности.

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

Вот документ с некоторыми датами, который анализирует лучшие практики для C/C++ в контексте встроенных систем: http://www.open-std.org/jtc1/sc22/wg21/docs/ESC_Boston_01_304_paper.pdf

В заключение: программист должен понять плюсы / минусы использования одной конструкции над другой. Если вы не сильно ориентированы на производительность, вы, вероятно, не заботитесь о снижении производительности и должны использовать все аккуратные OO-компоненты в C++, чтобы сделать ваш код максимально удобным для использования.

Снижение производительности при использовании виртуальных функций никогда не может перевесить преимущества, которые вы получаете на уровне разработки. Предположительно, вызов виртуальной функции будет на 25% менее эффективным, чем прямой вызов статической функции. Это потому, что существует уровень косвенности через VMT. Однако время, необходимое для выполнения вызова, обычно очень мало по сравнению с временем, затрачиваемым на фактическое выполнение вашей функции, поэтому общие затраты на производительность будут незначительными, особенно с учетом текущей производительности оборудования. Кроме того, компилятор может иногда оптимизировать и видеть, что виртуальный вызов не нужен, и компилировать его в статический вызов. Так что не волнуйтесь, используйте виртуальные функции и абстрактные классы столько, сколько вам нужно.

Я всегда сомневался в этом, тем более что - несколько лет назад - я также проводил такой тест, сравнивая время вызова стандартного метода-члена с виртуальным, и был действительно зол в то время, когда пустые виртуальные вызовы были В 8 раз медленнее, чем не виртуалы.

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

// g++ -std=c++0x -o perf perf.cpp -lrt
#include <typeinfo>    // typeid
#include <cstdio>      // printf
#include <cstdlib>     // atoll
#include <ctime>       // clock_gettime

struct Virtual { virtual int call() { return 42; } }; 
struct Inline { inline int call() { return 42; } }; 
struct Normal { int call(); };
int Normal::call() { return 42; }

template<typename T>
void test(unsigned long long count) {
    std::printf("Timing function calls of '%s' %llu times ...\n", typeid(T).name(), count);

    timespec t0, t1;
    clock_gettime(CLOCK_REALTIME, &t0);

    T test;
    while (count--) test.call();

    clock_gettime(CLOCK_REALTIME, &t1);
    t1.tv_sec -= t0.tv_sec;
    t1.tv_nsec = t1.tv_nsec > t0.tv_nsec
        ? t1.tv_nsec - t0.tv_nsec
        : 1000000000lu - t0.tv_nsec;

    std::printf(" -- result: %d sec %ld nsec\n", t1.tv_sec, t1.tv_nsec);
}

template<typename T, typename Ua, typename... Un>
void test(unsigned long long count) {
    test<T>(count);
    test<Ua, Un...>(count);
}

int main(int argc, const char* argv[]) {
    test<Inline, Normal, Virtual>(argc == 2 ? atoll(argv[1]) : 10000000000llu);
    return 0;
}

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

Другие вопросы по тегам