Какова производительность при наличии виртуального метода в классе C ++? - PullRequest
99 голосов
/ 20 марта 2009

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

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

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

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

Ответы [ 9 ]

92 голосов
/ 20 марта 2009

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

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

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

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

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

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

18 голосов
/ 20 марта 2009

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

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

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

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

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

9 голосов
/ 21 марта 2009

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

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

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

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

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

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

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

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

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

7 голосов
/ 20 марта 2009

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

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

5 голосов
/ 26 января 2011

Виртуальная диспетчеризация на порядок медленнее, чем некоторые альтернативы - не из-за косвенного обращения, а из-за предотвращения встраивания. Ниже я проиллюстрирую это, сравнивая виртуальную диспетчеризацию с реализацией, встраивающей «тип (-идентифицирующее) число» в объекты и использующей оператор 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 ВИРТУАЛЬНАЯ ОТПРАВКА

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

3 голосов
/ 20 марта 2009

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

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


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

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


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

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

2 голосов
/ 22 марта 2009

Хотя все остальные правы относительно производительности виртуальных методов и тому подобное, я думаю, что реальная проблема заключается в том, знает ли команда об определении виртуального ключевого слова в 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.

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

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

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

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

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

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

1 голос
/ 20 марта 2009

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

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

0 голосов
/ 20 марта 2009

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

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

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


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

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

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

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

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

  1. 3913 с
  2. 3873 сек
  3. 3970 с

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

Суть в том, что если вам не нравится аналогия с push / pop, подумайте о дополнительных if / else в вашем коде? Думаете ли вы о конвейере ЦП, когда добавляете дополнительные if / else ;-) Кроме того, вы никогда не знаете, на каком ЦП будет выполняться код ... Обычный компилятор может генерировать код, более оптимальный для одного ЦП и менее оптимальный для другого ( Компилятор Intel C ++ )

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...