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


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

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

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

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

9 91

9 ответов:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Если вам нужна функциональность виртуальной отправки, вы должны заплатить цену. Преимущество 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 раза быстрее. Конечно, это специфично для конкретной тестируемой системы / флагов компилятора и версии и т. д., но в целом показательно.


КОММЕНТАРИИ К ВИРТУАЛЬНОЙ ОТПРАВКЕ

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

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

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


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

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


надуманный пример: пустой виртуальный деструктор на массиве из миллиона мелких элементов может пропахать по крайней мере 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()

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

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

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

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

#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::Foo()
B::Foo()
A::Foo()

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

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

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

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

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

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

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

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

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


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

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

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

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

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

  1. 3,913 сек
  2. 3,873 сек
  3. 3,970 сек

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

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