Какова производительность при наличии виртуального метода в классе C++?
Наличие хотя бы одного виртуального метода в классе C++ (или любом из его родительских классов) означает, что у класса будет виртуальная таблица, а у каждого экземпляра будет виртуальный указатель.
Так что стоимость памяти вполне понятна. Наиболее важным является стоимость памяти для экземпляров (особенно, если экземпляры маленькие, например, если они просто должны содержать целое число: в этом случае наличие виртуального указателя в каждом экземпляре может удвоить размер экземпляров. пространство памяти, используемое виртуальными таблицами, я думаю, оно обычно ничтожно мало по сравнению с пространством, используемым фактическим кодом метода.
Это подводит меня к моему вопросу: существуют ли измеримые затраты производительности (то есть влияние на скорость) для создания метода виртуальным? Будет производиться поиск в виртуальной таблице во время выполнения, при каждом вызове метода, так что, если этот метод очень частый, и если этот метод очень короткий, то может быть измеримое снижение производительности? Я думаю, это зависит от платформы, но кто-нибудь запускал какие-то тесты?
Причина, по которой я спрашиваю, состоит в том, что я столкнулся с ошибкой, которая произошла из-за того, что программист забыл определить виртуальный метод. Это не первый раз, когда я вижу такую ошибку. И я подумал: почему мы добавляем виртуальное ключевое слово, когда это необходимо, вместо удаления виртуального ключевого слова, когда мы абсолютно уверены, что оно не нужно? Если производительность будет низкой, я думаю, я просто порекомендую в моей команде следующее: просто сделайте каждый метод виртуальным по умолчанию, включая деструктор, в каждом классе и удаляйте его только тогда, когда это необходимо. Это звучит для тебя безумно?
9 ответов
Я запустил несколько таймингов на процессоре PowerPC 3 Гц по порядку. В этой архитектуре вызов виртуальной функции стоит на 7 наносекунд дольше, чем прямой (не виртуальный) вызов функции.
Таким образом, не стоит беспокоиться о стоимости, если только функция не является чем-то вроде тривиального метода доступа Get()/Set(), в котором все, кроме inline, является расточительным. Накладные расходы на 7 нс для функции, которая встроена в 0,5 нс, являются серьезными; накладные расходы 7 нс на функцию, выполнение которой занимает 500 мс, не имеют смысла.
Большая стоимость виртуальных функций на самом деле не сводится к поиску указателя на функцию в vtable (обычно это всего один цикл), а в том, что косвенный переход обычно не может быть предсказан с помощью ветвления. Это может вызвать большой пузырь конвейера, так как процессор не может извлечь какие-либо инструкции до тех пор, пока не будет удален косвенный переход (вызов через указатель функции) и не будет вычислен новый указатель инструкции. Таким образом, стоимость вызова виртуальной функции намного больше, чем может показаться при просмотре сборки... но все равно всего 7 наносекунд.
Редактирование: Andrew, Not Sure и другие также поднимают очень хорошую мысль о том, что вызов виртуальной функции может привести к пропаданию кэша инструкций: если вы перейдете на адрес кода, который не находится в кэше, то вся программа полностью остановится, пока инструкции извлекаются из основной памяти. Это всегда значительный ларек: на ксеноне около 650 циклов (по моим тестам).
Однако это не проблема, специфичная для виртуальных функций, потому что даже прямой вызов функции приведет к ошибке, если вы перейдете к инструкциям, которых нет в кеше. Важно то, была ли функция запущена до недавнего времени (что делает ее более вероятной в кеше), и может ли ваша архитектура прогнозировать статические (не виртуальные) ветки и извлекать эти инструкции в кеш заранее. Мой PPC нет, но, возможно, самое последнее оборудование от Intel.
Мой тайминги контролируют влияние пропусков icache на выполнение (намеренно, поскольку я пытался исследовать конвейер ЦП изолированно), поэтому они снижают эту стоимость.
Определенно измеримые издержки при вызове виртуальной функции - вызов должен использовать vtable для разрешения адреса функции для этого типа объекта. Дополнительные инструкции - наименьшее из ваших беспокойств. Vtables не только предотвращают многие потенциальные оптимизации компилятора (так как тип является полиморфным для компилятора), они также могут уничтожить ваш I-Cache.
Конечно, являются ли эти штрафы значительными или нет, зависит от вашего приложения, как часто выполняются эти пути кода и ваши шаблоны наследования.
Однако, на мой взгляд, виртуальное все по умолчанию - это общее решение проблемы, которую вы можете решить другими способами.
Возможно, вы могли бы посмотреть, как классы разработаны / задокументированы / написаны. Как правило, заголовок класса должен четко указывать, какие функции могут быть переопределены производными классами и как они вызываются. Если программисты напишут эту документацию, это поможет убедиться, что они помечены как виртуальные.
Я также сказал бы, что объявление каждой функции как виртуальной может привести к большему количеству ошибок, чем просто забыть пометить что-то как виртуальное. Если все функции являются виртуальными, все можно заменить базовыми классами - публичными, защищенными, приватными - все становится честной игрой. Случайно или намеренно подклассы могут затем изменить поведение функций, которые затем вызывают проблемы при использовании в базовой реализации.
Это зависит.:) (Вы ожидали что-нибудь еще?)
Как только класс получает виртуальную функцию, он больше не может быть типом данных POD (возможно, он и раньше не был таким, и в этом случае это не будет иметь значения), что делает невозможным целый ряд оптимизаций.
std:: copy () для простых POD-типов может прибегнуть к простой подпрограмме memcpy, но не-POD-типы должны обрабатываться более осторожно.
Построение становится намного медленнее, потому что vtable должен быть инициализирован. В худшем случае разница в производительности между типами данных POD и не POD может быть значительной.
В худшем случае вы можете увидеть 5-кратное замедление выполнения (это число взято из университетского проекта, который я делал недавно, чтобы переопределить несколько стандартных библиотечных классов. Наш контейнер занял примерно 5-кратное время для создания, как только тип данных, которые он сохранил, получил виртуальные таблицы)
Конечно, в большинстве случаев вы вряд ли увидите какую-либо измеримую разницу в производительности, это просто означает, что в некоторых пограничных случаях это может быть дорогостоящим.
Тем не менее, производительность не должна быть вашим главным соображением здесь. Сделать все виртуальным не идеальное решение по другим причинам.
Разрешение переопределения всего в производных классах значительно усложняет поддержку инвариантов классов. Как класс гарантирует, что он остается в согласованном состоянии, когда любой из его методов может быть переопределен в любое время?
Виртуализация всего может устранить некоторые потенциальные ошибки, но это также вводит и новые.
Если вам нужна функциональность виртуальной отправки, вы должны заплатить цену. Преимущество C++ в том, что вы можете использовать очень эффективную реализацию виртуальной диспетчеризации, предоставляемую компилятором, а не потенциально неэффективную версию, которую вы реализуете сами.
Тем не менее, если вы не будете нуждаться в этом, вам не хватит лишних затрат, это может зайти слишком далеко. И большинство классов не предназначены для наследования - для создания хорошего базового класса требуется нечто большее, чем виртуальные функции.
Виртуальная диспетчеризация на порядок медленнее, чем некоторые альтернативы - не из-за косвенного обращения, а из-за предотвращения встраивания. Ниже я проиллюстрирую это, сравнивая виртуальную диспетчеризацию с реализацией, встраивающей "тип (-идентифицирующее) число" в объекты и использующей оператор switch для выбора специфичного для типа кода. Это полностью исключает накладные расходы при вызове функции - просто делает локальный переход. Существует потенциальная стоимость обслуживания, перекомпиляции зависимостей и т. Д. Через принудительную локализацию (в коммутаторе) функциональности, специфичной для типа.
РЕАЛИЗАЦИЯ
#include <iostream>
#include <vector>
// 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<Base*> 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<Type*> 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 раза быстрее. Конечно, это зависит от конкретной тестируемой системы / флагов и версии компилятора и т. Д., Но в целом показательно.
КОММЕНТАРИИ RE ВИРТУАЛЬНАЯ ОТПРАВКА
Однако следует отметить, что издержки вызова виртуальных функций редко бывают значительными, и то только для часто называемых тривиальных функций (таких как методы получения и установки). Даже в этом случае вы можете предоставить единственную функцию для получения и установки множества вещей одновременно, минимизируя затраты. Люди слишком сильно беспокоятся о виртуальной рассылке - так что делайте профилирование, прежде чем искать неловкие альтернативы. Основная проблема с ними заключается в том, что они выполняют вызов функции вне линии, хотя они также делокализуют исполняемый код, который изменяет шаблоны использования кэша (к лучшему или (чаще)).
Дополнительные расходы практически ничего не значат в большинстве сценариев. (простите за каламбур). Ejac уже опубликовал разумные относительные меры.
Самое большое, от чего вы отказываетесь, - это возможные оптимизации из-за встраивания. Они могут быть особенно хороши, если функция вызывается с постоянными параметрами. Это редко имеет реальное значение, но в некоторых случаях это может быть огромным.
Что касается оптимизации:
Важно знать и учитывать относительную стоимость конструкций вашего языка. Нотация Big O - это только половина истории - как масштабируется ваше приложение. Другая половина - постоянный фактор перед ним.
Как правило, я бы не стал избегать виртуальных функций, если бы не было четких и конкретных указаний на то, что это бутылочное горлышко. Чистый дизайн всегда на первом месте - но это только одна заинтересованная сторона, которая не должна чрезмерно вредить другим.
Придуманный пример: пустой виртуальный деструктор в массиве из одного миллиона мелких элементов может проламывать как минимум 4 МБ данных, что приводит к перегрузке вашего кэша. Если этот деструктор может быть встроен, данные не будут затронуты.
При написании библиотечного кода такие соображения далеко не преждевременны. Вы никогда не знаете, сколько циклов будет помещено вокруг вашей функции.
В то время как все остальные правы относительно производительности виртуальных методов и тому подобное, я думаю, что реальная проблема заключается в том, знает ли команда об определении виртуального ключевого слова в C++.
Рассмотрите этот код, что является выходом?
#include <stdio.h>
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 в классах A и B, мы получим это для вывода:
A::Foo()
B::Foo()
B::Foo()
В значительной степени то, что все ожидают.
Вы упомянули, что есть ошибки, потому что кто-то забыл добавить виртуальное ключевое слово. Так что рассмотрим этот код (где виртуальное ключевое слово добавляется в класс А, а не в класс В). Какой выход тогда?
#include <stdio.h>
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;
}
Ответ: так же, как если бы виртуальное ключевое слово было добавлено в B? Причина в том, что подпись для B::Foo совпадает точно так же, как A::Foo(), и поскольку Foo для A является виртуальным, то же самое относится и к B.
Теперь рассмотрим случай, когда B's Foo является виртуальным, а A - нет. Какой выход тогда? В этом случае вывод
A::Foo()
B::Foo()
A::Foo()
Виртуальное ключевое слово работает вниз в иерархии, а не вверх. Это никогда не делает методы базового класса виртуальными. Первый раз, когда виртуальный метод встречается в иерархии, это когда начинается полиморфизм. Для более поздних классов нет способа сделать так, чтобы у предыдущих классов были виртуальные методы.
Не забывайте, что виртуальные методы означают, что этот класс дает будущим классам возможность переопределять / изменять некоторые из его поведений.
Поэтому, если у вас есть правило для удаления виртуального ключевого слова, оно может не дать ожидаемого эффекта.
Виртуальное ключевое слово в C++ - мощная концепция. Вы должны убедиться, что каждый член команды действительно знает эту концепцию, чтобы ее можно было использовать по назначению.
В зависимости от вашей платформы издержки виртуального вызова могут быть очень нежелательными. Объявляя каждую функцию виртуальной, вы фактически вызываете их все через указатель на функцию. По крайней мере, это дополнительная разыменование, но на некоторых платформах PPC для этого используются микрокодированные или иные медленные инструкции.
По этой причине я бы рекомендовал против вашего предложения, но если это поможет вам предотвратить ошибки, то это может стоить компромисса. Я не могу не думать, что должна быть какая-то золотая середина, которую стоит найти.
Для вызова виртуального метода потребуется всего пара дополнительных asm-инструкций.
Но я не думаю, что вы беспокоитесь о том, что в fun(int a, int b) есть пара дополнительных "push" инструкций по сравнению с fun(). Так что не беспокойтесь и о виртуалах, пока вы не окажетесь в особой ситуации и не увидите, что это действительно приводит к проблемам.
PS Если у вас есть виртуальный метод, убедитесь, что у вас есть виртуальный деструктор. Таким образом, вы избежите возможных проблем
В ответ на комментарии "xtofl" и "Tom". Я сделал небольшие тесты с 3 функциями:
- виртуальный
- Нормальный
- Нормальный с 3 int параметрами
Мой тест был простой итерацией:
for(int it = 0; it < 100000000; it ++) {
test.Method();
}
И вот результаты:
- 3913 с
- 3873 с
- 3970 сек
Он был скомпилирован VC++ в режиме отладки. Я выполнил только 5 тестов на метод и вычислил среднее значение (поэтому результаты могут быть довольно неточными). В любом случае, значения почти равны при условии 100 миллионов вызовов. И метод с 3 дополнительными push/pop был медленнее.
Суть в том, что если вам не нравится аналогия с push/pop, подумайте о дополнительных if / else в вашем коде? Думаете ли вы о конвейере ЦП, когда добавляете дополнительные if/else;-) Кроме того, вы никогда не знаете, на каком ЦП будет выполняться код... Обычный компилятор может генерировать код, более оптимальный для одного ЦП и менее оптимальный для другого ( Intel Компилятор C++)