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


125

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


В соответствии с моим ответом, я предлагаю закрыть это как дубликат stackoverflow.com/questions/113830
Suma


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

Ответы:


90

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

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

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

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


А как насчет чистых виртуальных функций? Они как-то влияют на производительность? Просто интересно, как кажется, что они существуют просто для обеспечения выполнения.
thomthom

2
@thomthom: Правильно, нет разницы в производительности между чисто виртуальными и обычными виртуальными функциями.
Грег Хьюгилл,

168

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

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

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


Но если их вызывают несколько раз, они часто могут быть дешевле, чем когда их вызывают только один раз. См. Мой нерелевантный блог: phresnel.org/blog , сообщения под заголовком «Виртуальные функции считаются безопасными», но, конечно, это зависит от сложности ваших кодовых путей
Себастьян Мах

22
Мой тест измеряет небольшой набор виртуальных функций, вызываемых повторно. В вашем сообщении в блоге предполагается, что временные затраты на код можно измерить путем подсчета операций, но это не всегда так; основная стоимость vfunc на современных процессорах - это «пузырек» конвейера, вызванный неверным предсказанием ветвления.
Crashworks

10
это было бы отличным тестом для gcc LTO (оптимизация времени соединения); попробуйте снова скомпилировать это с включенным lto: gcc.gnu.org/wiki/LinkTimeOptimization и посмотрите, что произойдет с фактором 20x
lurscher

1
Если в классе есть одна виртуальная и одна встроенная функция, будет ли затронута производительность невиртуального метода? Просто по природе виртуального класса?
thomthom

4
@thomthom Нет, виртуальный / не виртуальный - это атрибут для каждой функции. Функция должна быть определена через vtable только в том случае, если она помечена как виртуальная или если она переопределяет базовый класс, который имеет ее как виртуальный. Вы часто будете видеть классы, которые имеют группу виртуальных функций для публичного интерфейса, а затем множество встроенных средств доступа и так далее. (Технически это зависит от реализации, и компилятор может использовать виртуальные понтеры даже для функций, помеченных как «встроенные», но человек, который написал такой компилятор, был бы безумен.)
Crashworks

42

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


4
Я не уверен, что iPhone - хороший пример работоспособного кода: youtube.com/watch?v=Pdk2cJpSXLg
Crashworks

13
@Crashworks: iPhone вообще не пример кода. Это пример оборудования - особенно медленного оборудования , о чем я здесь и говорил. Если эти якобы «медленные» языки достаточно хороши для оборудования с недостаточной мощностью, виртуальные функции не будут большой проблемой.
Чак

52
IPhone работает на процессоре ARM. Процессоры ARM, используемые для iOS, предназначены для использования с низкой частотой и низким энергопотреблением. В ЦП нет микросхемы для прогнозирования ветвлений, и поэтому нет накладных расходов на производительность из-за пропусков прогнозирования ветвлений из-за вызовов виртуальных функций. Кроме того, частота МГц для оборудования iOS достаточно мала, чтобы промах в кэше не остановил процессор на 300 тактов, пока он извлекает данные из ОЗУ. Промахи кэша менее важны на более низких частотах. Короче говоря, использование виртуальных функций на устройствах iOS не требует дополнительных затрат, но это проблема оборудования и не относится к процессорам настольных компьютеров.
HaltingState

4
Как давний Java-программист, недавно освоивший C ++, я хочу добавить, что JIT-компилятор Java и оптимизатор времени выполнения имеют возможность компилировать, прогнозировать и даже встраивать некоторые функции во время выполнения после заранее определенного количества циклов. Однако я не уверен, есть ли у C ++ такая функция во время компиляции и компоновки, потому что в нем отсутствует шаблон вызова во время выполнения. Таким образом, в C ++ нам, возможно, придется быть немного более осторожными.
Alex Suo

@AlexSuo Я не уверен в твоей точке зрения? Будучи скомпилированным, C ++, конечно, не может оптимизировать на основе того, что может произойти во время выполнения, поэтому прогнозирование и т. Д. Должно выполняться самим процессором ... но хорошие компиляторы C ++ (если проинструктированы) идут на многое, чтобы оптимизировать функции и циклы задолго до во время выполнения.
underscore_d

34

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

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

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

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


6
Верно, но любой код (или vtable), который многократно вызывается из жесткого цикла, (конечно) редко страдает промахами кеша. Кроме того, указатель vtable обычно находится в той же строке кэша, что и другие данные в объекте, к которому будет обращаться вызываемый метод, поэтому часто мы говорим только об одном дополнительном промахе в кэше.
Qwertie

5
@Qwertie Я не думаю, что это необходимо, правда. Тело цикла (если оно больше, чем кэш L1) может «удалить» указатель vtable, указатель на функцию и последующая итерация должны будут ждать доступа к кешу L2 (или большему количеству) на каждой итерации
Гита

30

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

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


Спасибо за ссылку. Руководства по оптимизации Agner Fog - золотой стандарт для оптимального использования оборудования.
Арто Бендикен

Основываясь на моих воспоминаниях и быстром поиске - stackoverflow.com/questions/17061967/c-switch-and-jump-tables - я сомневаюсь, что это всегда верно switch. caseКонечно, с абсолютно произвольными значениями. Но если все cases являются последовательными, компилятор мог бы оптимизировать это в таблицу переходов (ах, это напоминает мне старые добрые дни Z80), которая должна быть (за отсутствием лучшего термина) постоянным временем. Не то чтобы я рекомендовал пытаться заменить vfuncs на switch, что абсурдно . ;)
underscore_d

7

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

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

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

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


4
Что ж, даже сегодня для высокопроизводительных приложений важно избегать вызовов функций. Разница в том, что современные компиляторы надежно встраивают небольшие функции, поэтому мы не страдаем от потери скорости при написании небольших функций. Что касается виртуальных функций, интеллектуальные процессоры могут выполнять для них интеллектуальное прогнозирование ветвлений. Я думаю, что тот факт, что старые компьютеры были медленнее, на самом деле не проблема - да, они были намного медленнее, но тогда мы знали об этом и поэтому давали им гораздо меньшие рабочие нагрузки. В 1992 году, если бы мы проигрывали MP3, мы знали, что нам, возможно, придется выделить более половины процессора для этой задачи.
Qwertie

6
mp3 датируется 1995 годом. В 92 году у нас едва было 386, они никак не могли воспроизвести mp3, а 50% времени процессора предполагает наличие хорошей многозадачной ОС, незанятого процесса и упреждающего планировщика. Ничего подобного на потребительском рынке в то время не существовало. это было 100% с момента включения питания, конец истории.
v.oddou

7

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

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

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

Кстати, вот сообщение, которое я нашел, в котором говорится об аспектах производительности процессора.



4

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


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

4

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

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


2
вызов чего-либо в жестком цикле, вероятно, сохранит весь этот код и данные в кеше ...
Грег Роджерс,

2
Да, но если этот правый цикл выполняет итерацию по списку объектов, то каждый объект потенциально может вызывать виртуальную функцию по другому адресу через один и тот же вызов функции.
Daemin 02

3

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

Это хорошо видно на тесте, разница во времени ~ 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;
}

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

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


4
Вызов виртуальной функции стоит дорого по сравнению с ++ia. Ну и что?
Бо Перссон,

2

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

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

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

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


2

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


1

Следует отметить, что это:

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;
}

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

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


0

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


2
никогда, каким бы маленьким ни был целевой компьютер?
zumalifeguard

Я мог бы согласиться, если бы вы сформулировали это как The performance penalty of using virtual functions can sometimes be so insignificant that it is completely outweighed by the advantages you get at the design level.«Ключевое различие в том sometimes, что нет» never.
underscore_d

-1

Я всегда сомневался в этом, тем более, что - несколько лет назад - я также провел такой тест, сравнивая тайминги стандартного вызова метода члена с виртуальным, и был очень зол на результаты в то время, поскольку пустые виртуальные вызовы были В 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;
}

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


12
Я думаю, что вполне вероятно, что ваш компилятор может сказать, что вызов виртуальной функции в вашем коде может вызывать только Virtual :: call. В этом случае его можно просто встроить. Также ничто не мешает компилятору встраивать Normal :: call, даже если вы этого не просили. Поэтому я думаю, что вполне возможно, что вы получите одинаковое время для трех операций, потому что компилятор генерирует для них идентичный код.
Bjarke H. Roune 06
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.