Какова стоимость выполнения виртуального метода в classе C ++?

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

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

Это подводит меня к моему вопросу: существует ли измеримая стоимость исполнения (например, влияние скорости) для создания виртуального метода? Будет ли поиск в виртуальной таблице во время выполнения, при каждом вызове метода, поэтому, если есть очень частые вызовы этого метода, и если этот метод очень короткий, то может быть измеримое поражение производительности? Я думаю, это зависит от платформы, но кто-нибудь запускает некоторые тесты?

Причина, по которой я спрашиваю, заключается в том, что я столкнулся с ошибкой, которая произошла из-за того, что программист забыл определить метод виртуальный. Это не первый раз, когда я вижу такую ​​ошибку. И я подумал: почему мы добавляем ключевое слово virtual при необходимости вместо удаления ключевого слова virtual, когда мы абсолютно уверены, что он не нужен? Если стоимость исполнения низкая, я думаю, что я просто рекомендую следующее в своей команде: просто сделайте каждый метод виртуальным по умолчанию, включая деструктор, в каждом classе и удалите его только тогда, когда вам нужно. Это звучит безумно для вас?

Я провел несколько таймингов на процессоре PowerPC размером 3 ГГц. В этой архитектуре вызов виртуальной функции стоит 7 наносекунд дольше, чем прямой (не виртуальный) вызов функции.

Поэтому не стоит беспокоиться о стоимости, если функция не является чем-то вроде тривиального Access () / Set (), в котором ничего, кроме встроенного, не является расточительным. Накладные расходы 7ns на функцию, которая составляет до 0,5 нс, являются серьезными; накладные расходы 7ns на функцию, которая занимает 500 мс для выполнения, не имеют смысла.

Большая стоимость виртуальных функций – это не поиск указателя функции в таблице vtable (обычно это всего лишь один цикл), но косвенный прыжок обычно не может быть предсказан ветвью. Это может привести к большому пузырьку трубопровода, поскольку процессор не может получить какие-либо инструкции до тех пор, пока косвенный переход (вызов через указатель функции) не будет удален, а новый указатель команды вычислен. Таким образом, стоимость вызова виртуальных функций намного больше, чем может показаться, глядя на сборку … но все же только 7 наносекунд.

Edit: Andrew, Not Sure, и другие также поднимают очень хорошую точку зрения, что вызов виртуальной функции может привести к провалу кэша команд: если вы перейдете на адрес кода, который не находится в кеше, тогда вся программа останавливается, а инструкции извлекаются из основной памяти. Это всегда значительная стойка: на Xenon, около 650 циклов (по моим тестам).

Однако это не проблема, характерная для виртуальных функций, потому что даже прямой вызов функции приведет к промаху, если вы перейдете к инструкциям, которые не находятся в кеше. Важно то, была ли эта функция запущена до недавнего времени (что, скорее всего, было в кеше), и может ли ваша архитектура предсказать статические (а не виртуальные) ветви и заранее извлечь эти инструкции в кеш. Мой PPC не делает, но, возможно, самое последнее оборудование Intel.

Мой контроль таймингов за влияние промахов icache на выполнение (сознательно, поскольку я пытался изолировать конвейер процессора), поэтому они снижают эту стоимость.

При вызове виртуальной функции определенно измеримые накладные расходы – вызов должен использовать vtable для разрешения адреса функции для этого типа объекта. Дополнительные инструкции являются наименьшими из ваших забот. Не только vtables предотвращают многие потенциальные оптимизации компилятора (поскольку тип является полиморфным компилятором), они также могут разбивать ваш I-Cache.

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

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

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

Я бы также сказал, что объявление каждой функции как виртуальной может привести к большему количеству ошибок, чем просто забыть отметить что-то как виртуальное. Если все функции виртуальны, все может быть заменено базовыми classами – публичными, защищенными, частными – все становится честной игрой. Подclass или намерение подclassы могли бы затем изменить поведение функций, которые затем вызывают проблемы при использовании в базовой реализации.

Это зависит. 🙂 (Вы ожидали чего-нибудь еще?)

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

std :: copy () на простых типах POD может прибегать к простой процедуре memcpy, но не-POD-типы должны обрабатываться более тщательно.

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

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

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

Тем не менее, производительность не должна быть вашим основным соображением здесь. Сделать все виртуальным не является идеальным решением по другим причинам.

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

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

Если вам нужна функциональность виртуальной отправки, вам нужно заплатить цену. Преимущество C ++ заключается в том, что вы можете использовать очень эффективную реализацию виртуальной отправки, предоставляемой компилятором, а не возможную неэффективную версию, которую вы реализуете самостоятельно.

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

Виртуальная диспетчеризация на порядок медленнее, чем некоторые альтернативы, – не из-за косвенности, как предотrotation встраивания. Ниже я проиллюстрирую это, сравнивая виртуальную отправку с реализацией, внедряя «тип (идентификационный номер)» в объекты и используя оператор switch для выбора кода, специфичного для конкретного типа. Это позволяет полностью избежать накладных вызовов функций – просто выполняет локальный скачок. Существует потенциальная стоимость ремонтопригодности, зависимостей перекомпиляции и т. Д. Посредством принудительной локализации (в коммутаторе) специфичной для конкретного типа функциональности.


РЕАЛИЗАЦИЯ

 #include  #include  // virtual dispatch model... struct Base { virtual int f() const { return 1; } }; struct Derived : Base { virtual int f() const { return 2; } }; // alternative: member variable encodes runtime type... struct Type { Type(int type) : type_(type) { } int type_; }; struct A : Type { A() : Type(1) { } int f() const { return 1; } }; struct B : Type { B() : Type(2) { } int f() const { return 2; } }; struct Timer { Timer() { clock_gettime(CLOCK_MONOTONIC, &from); } struct timespec from; double elapsed() const { struct timespec to; clock_gettime(CLOCK_MONOTONIC, &to); return to.tv_sec - from.tv_sec + 1E-9 * (to.tv_nsec - from.tv_nsec); } }; int main(int argc) { for (int j = 0; j < 3; ++j) { typedef std::vector V; V v; for (int i = 0; i < 1000; ++i) v.push_back(i % 2 ? new Base : (Base*)new Derived); int total = 0; Timer tv; for (int i = 0; i < 100000; ++i) for (V::const_iterator i = v.begin(); i != v.end(); ++i) total += (*i)->f(); double tve = tv.elapsed(); std::cout << "virtual dispatch: " << total << ' ' << tve << '\n'; // ---------------------------- typedef std::vector W; W w; for (int i = 0; i < 1000; ++i) w.push_back(i % 2 ? (Type*)new A : (Type*)new B); total = 0; Timer tw; for (int i = 0; i < 100000; ++i) for (W::const_iterator i = w.begin(); i != w.end(); ++i) { if ((*i)->type_ == 1) total += ((A*)(*i))->f(); else total += ((B*)(*i))->f(); } double twe = tw.elapsed(); std::cout << "switched: " << total << ' ' << twe << '\n'; // ---------------------------- total = 0; Timer tw2; for (int i = 0; i < 100000; ++i) for (W::const_iterator i = w.begin(); i != w.end(); ++i) total += (*i)->type_; double tw2e = tw2.elapsed(); std::cout << "overheads: " << total << ' ' << tw2e << '\n'; } } 

РЕЗУЛЬТАТЫ ДЕЯТЕЛЬНОСТИ

В моей системе Linux:

 ~/dev g++ -O2 -o vdt vdt.cc -lrt ~/dev ./vdt virtual dispatch: 150000000 1.28025 switched: 150000000 0.344314 overhead: 150000000 0.229018 virtual dispatch: 150000000 1.285 switched: 150000000 0.345367 overhead: 150000000 0.231051 virtual dispatch: 150000000 1.28969 switched: 150000000 0.345876 overhead: 150000000 0.230726 

Это говорит о том, что встроенный подход с коммутированием по типу номера приблизительно равен (1,28-0,23) / (0,344-0,23) = 9,2 раза быстрее. Конечно, это специфично для точных системных тестов / флагов / версии компилятора и т. Д., Но в целом показательно.


КОММЕНТАРИИ ВИРТУАЛЬНЫЙ ДИСПЕТЧЕР

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

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

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


Что касается оптимизации:
Важно знать и учитывать относительную стоимость конструкций вашего языка. Обозначение Big O – половина истории – как ваш масштаб приложения . Другая половина – постоянный фактор перед ним.

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


Продуманный пример: пустой виртуальный деструктор в массиве из миллиона небольших элементов может пробивать не менее 4 МБ данных, избивая кеш. Если этот деструктор можно отбросить, данные не будут затронуты.

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

В то время как все остальные правильно оценивают эффективность виртуальных методов и т. Д., Я думаю, что реальная проблема заключается в том, знает ли команда об определении ключевого слова virtual в C ++.

Рассмотрим этот код, каков результат?

 #include  class A { public: void Foo() { printf("A::Foo()\n"); } }; class B : public A { public: void Foo() { printf("B::Foo()\n"); } }; int main(int argc, char** argv) { A* a = new A(); a->Foo(); B* b = new B(); b->Foo(); A* a2 = new B(); a2->Foo(); return 0; } 

Ничего удивительного здесь:

 A::Foo() B::Foo() A::Foo() 

Поскольку ничто не является виртуальным. Если ключевое слово virtual добавлено в начало Foo в classах A и B, мы получим это для вывода:

 A::Foo() B::Foo() B::Foo() 

В значительной степени то, что все ожидают.

Теперь вы упомянули, что есть ошибки, потому что кто-то забыл добавить виртуальное ключевое слово. Поэтому рассмотрим этот код (где ключевое слово virtual добавлено в A, но не в class B). Каков результат?

 #include  class A { public: virtual void Foo() { printf("A::Foo()\n"); } }; class B : public A { public: void Foo() { printf("B::Foo()\n"); } }; int main(int argc, char** argv) { A* a = new A(); a->Foo(); B* b = new B(); b->Foo(); A* a2 = new B(); a2->Foo(); return 0; } 

Ответ: Точно так же, как если бы ключевое слово virtual было добавлено в B? Причина в том, что подпись для B :: Foo совпадает с A :: Foo () и потому, что A’s Foo является виртуальным, то есть B.

Теперь рассмотрим случай, когда Boo Foo является виртуальным, а A – нет. Каков результат? В этом случае выход

 A::Foo() B::Foo() A::Foo() 

Ключевое слово virtual работает вниз в иерархии, а не вверх. Это никогда не делает виртуальные методы базового classа. В первый раз, когда виртуальный метод встречается в иерархии, начинается polymorphism. Для более поздних classов нет способа сделать предыдущие classы виртуальными методами.

Не забывайте, что виртуальные методы означают, что этот class дает будущим classам возможность переопределять / изменять некоторые из его поведений.

Поэтому, если у вас есть правило для удаления ключевого слова virtual, оно может не иметь предполагаемого эффекта.

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

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

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

Для вызова виртуального метода потребуется всего несколько дополнительных команд asm.

Но я не думаю, что вы беспокоитесь, что забава (int a, int b) содержит пару дополнительных «push» инструкций по сравнению с fun (). Поэтому не беспокойтесь о виртуальных играх, пока вы не попадете в особую ситуацию и не увидите, что это действительно приводит к проблемам.

PS Если у вас есть виртуальный метод, убедитесь, что у вас есть виртуальный деструктор. Таким образом, вы избежите возможных проблем


В ответ на комментарии «xtofl» и «Tom». Я сделал небольшие тесты с тремя функциями:

  1. виртуальный
  2. Нормальный
  3. Нормальный с 3 параметрами int

Мой тест был простой итерацией:

 for(int it = 0; it < 100000000; it ++) { test.Method(); } 

И вот результаты:

  1. 3,913 с
  2. 3,873 с
  3. 3970 с

Он был скомпилирован VC ++ в режиме отладки. Я сделал всего 5 тестов на один метод и вычислил среднее значение (поэтому результаты могут быть довольно неточными) ... В любом случае значения почти равны, если принять 100 миллионов вызовов. И метод с 3 дополнительными push / pop был медленнее.

Главное, что если вам не нравится аналогия с push / pop, подумайте о дополнительном if / else в вашем коде? Вы думаете о конвейере CPU при добавлении дополнительных if / else 😉 Кроме того, вы никогда не знаете, на каком процессоре будет работать код ... Обычный компилятор может генерировать код более оптимальным для одного процессора и менее оптимальным для другого ( Intel Компилятор C ++ )

  • Можно использовать профилировщик, но почему бы не просто остановить программу?
  • Какой цикл имеет лучшую производительность? Зачем?
  • Быстрее ли подсчитывать, чем подсчитывать?
  • Тернарный оператор в два раза медленнее, чем блок if-else?
  • Лучший способ заменить многие строки - обфускация в C #
  • Рекурсия или итерация?
  • Действительно ли ADD 1 быстрее INC? x86
  • Являются ли статические вызовы Java более или менее дорогостоящими, чем нестатические вызовы?
  • Как индексировать векторную последовательность в векторной последовательности
  • boolean против BitSet: что более эффективно?
  • Делают ли c ++ шаблоны медленными?
  • Давайте будем гением компьютера.