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

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

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

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

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

Ответ 1

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

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

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

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

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

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

Ответ 2

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

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

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

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

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

Ответ 3

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

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

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

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

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

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

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

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

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

Ответ 4

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

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

Ответ 5

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


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

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

Ответ 6

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

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


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

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


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

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

Ответ 7

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

Рассмотрим этот код, каков выходной?

#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()

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

Теперь вы упомянули, что есть ошибки, потому что кто-то забыл добавить виртуальное ключевое слово. Поэтому рассмотрим этот код (где ключевое слово virtual добавлено в 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;
}

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

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

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

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

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

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

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

Ответ 8

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

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

Ответ 9

Просто, чтобы дать представление о стоимости, я просто что-то прочитал об этом в Code Complete by Steve McConnell, глава 25.3:

относительные затраты:

  • целочисленное назначение: 1
  • Процедура вызова без параметров: 1
  • Вызов частной процедуры с 1 параметром: 1.5
  • Производный рутинный вызов: 2
  • Полиморфный рутинный вызов: 2.5

Хотя tis основан на старых данных из первого издания книги и может сильно варьироваться в зависимости от компилятора, системы,...

Единственный способ узнать - измерить

Его выводы:

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

Ответ 10

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

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

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


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

  • Виртуальный
  • Normal
  • Нормальный с 3 параметрами int

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

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

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

  • 3,913 с
  • 3,873 с
  • 3,970 с

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

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