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


107

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

Тож вартість пам'яті цілком зрозуміла. Найважливішою є вартість пам'яті для екземплярів (особливо якщо екземпляри невеликі, наприклад, якщо вони просто призначені для містять ціле число: у цьому випадку наявність віртуального вказівника у кожному екземплярі може подвоїти розмір примірників. Що стосується простір пам'яті, що використовується віртуальними таблицями, я думаю, що це зазвичай незначно порівняно з простором, який використовується фактичним кодом методу.

Це підводить мене до мого питання: чи є вимірна вартість продуктивності (тобто швидкість впливу) для створення методу віртуального? Під час виконання кожного виклику методу буде виконуватися пошук у віртуальній таблиці під час виконання виклику методу, тож якщо є дуже часті дзвінки до цього методу, і якщо цей метод дуже короткий, можливо, може бути досягнуто показника ефективності? Я думаю, це залежить від платформи, але хтось запускав якісь орієнтири?

Причина, про яку я запитую, полягає в тому, що я натрапив на помилку, яка трапилася через те, що програміст забув визначити метод віртуальний. Це не перший раз, коли я бачу подібну помилку. І я подумав: чому ми додаємо віртуальне ключове слово при необхідності замість видалення віртуального ключового слова, коли ми абсолютно впевнені, що воно не потрібне? Якщо вартість продуктивності низька, я думаю, що я просто рекомендую в своїй команді наступне: просто зробіть кожен метод за замовчуванням віртуальним, включаючи деструктор, у кожному класі та видаліть його лише тоді, коли вам потрібно. Це звучить для вас божевільно?



7
Порівнювати віртуальний з невіртуальними дзвінками не є загрозливим. Вони забезпечують різну функціональність. Якщо ви хочете порівняти виклики віртуальних функцій з еквівалентним С, вам потрібно додати вартість коду, який реалізує еквівалентну функцію віртуальної функції.
Мартін Йорк

Що є або оператором switch, або великим if. Якби ви були розумні, ви могли б повторно реалізувати за допомогою таблиці вказівників функцій, але ймовірності помилятися набагато вище.
Мартін Йорк


7
Питання полягає у викликах функцій, які не повинні бути віртуальними, тому порівняння має сенс.
Марк Викуп 11

Відповіді:


104

Я провів кілька таймінгів на процесорі PowerPC 3 ГГц. У цій архітектурі віртуальний виклик функції коштує на 7 наносекунд довше, ніж прямий (невіртуальний) виклик функції.

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

Велика вартість віртуальних функцій насправді не є пошуком функціонального вказівника у vtable (зазвичай це лише один цикл), але те, що непрямий стрибок зазвичай не можна передбачити гілкою. Це може спричинити велику бульбашку трубопроводу, оскільки процесор не може отримати жодних вказівок, поки непрямий стрибок (виклик через покажчик функції) не припиниться і не буде розраховано новий покажчик інструкцій. Отже, вартість виклику віртуальної функції набагато більша, ніж могло б здатися при перегляді збірки ... але все ж лише 7 наносекунд.

Редагувати: Ендрю, Не впевнений та інші також підкреслюють, що виклик віртуальної функції може призвести до пропуску кешу інструкцій: якщо ви перейдете до кодової адреси, яка не знаходиться в кеші, то вся програма зупиняється, тоді як інструкції витягнуті з основної пам'яті. Це завжди значна зупинка: на Xenon - близько 650 циклів (за моїми тестами).

Однак це не проблема, характерна для віртуальних функцій, тому що навіть прямий виклик функції призведе до пропуску, якщо перейти до інструкцій, яких немає в кеші. Важливо те, чи функція виконувалась недавно (що робить її швидше в кеш-пам'яті), і чи може ваша архітектура передбачити статичні (а не віртуальні) гілки та заздалегідь отримати ці інструкції в кеш. Мій КПП цього немає, але, можливо, це найновіше обладнання Intel.

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


3
Вартість за циклами приблизно дорівнює кількості ступенів трубопроводу між вилученням та кінцем відгалуження-відставки. Це не незначна вартість, і вона може доповнити, але якщо ви не намагаєтеся написати щільний високоефективний цикл, вам, мабуть, є більша парфюмерна риба.
Crashworks

7 наносекунд довше, ніж що. Якщо звичайний дзвінок становить 1 наносекунд, це важливо, якщо нормальний дзвінок становить 70 наносекунд, то це не так.
Мартін Йорк

Якщо ви подивитеся на таймінги, я виявив, що для функції, яка коштує 0,66 вбудованого, диференціальний накладний виклик прямого виклику функції становив 4,8ns, а віртуальної функції 12,3ns (порівняно з вбудованим). Ви добре зазначаєте, що якщо сама функція коштує мілісекунди, то 7 нс нічого не означає.
Crashworks

2
Більше, як 600 циклів, але це хороший момент. Я залишив це поза часом, тому що мене зацікавив лише надмірний обсяг через міхур трубопроводу та пролог / епілог. Пропуск icache трапляється так само легко для прямого виклику функції (у Ксенона немає передбачувача гілки icache).
Crashworks

2
Незначні деталі, але щодо "Однак це не проблема, специфічна для ...", це набагато гірше для віртуальної відправки, оскільки є додаткова сторінка (або дві, якщо вона трапляється через межу сторінки), яка повинна бути в кеші - для віртуальної таблиці відправки класу.
Тоні Делрой

19

При виклику віртуальної функції безумовно є вимірні накладні витрати - виклик повинен використовувати vtable для вирішення адреси функції для цього типу об’єктів. Додаткові інструкції - це найменша стурбованість. Мало того, що vtables запобігають багатьом потенційним оптимізаціям компілятора (оскільки тип поліморфний компілятору), вони також можуть зруйнувати ваш I-кеш.

Звичайно, чи є ці покарання суттєвими чи ні, залежить від вашої заявки, від того, наскільки часто виконуються ці кодові шляхи та вашої схеми успадкування.

На мою думку, однак, якщо все є віртуальним за замовчуванням, це повне рішення проблеми, яку можна було б вирішити іншими способами.

Можливо, ви могли б поглянути на те, як оформляються / документуються / пишуться заняття. Як правило, заголовок для класу повинен чітко пояснювати, які функції можуть бути замінені похідними класами та як вони викликаються. Дозволяти програмістам писати цю документацію корисно для того, щоб вони правильно позначені як віртуальні.

Я б також сказав, що оголошення кожної функції віртуальною може призвести до появи більше помилок, ніж просто забути позначити щось як віртуальне. Якщо всі функції віртуальні, все можна замінити базовими класами - публічними, захищеними, приватними - все стає чесною грою. Випадково або умисло підкласи можуть змінити поведінку функцій, які потім викликають проблеми при використанні в базовій реалізації.


Найбільша втрачена оптимізація - це вбудована, особливо якщо віртуальна функція часто мала чи порожня.
Зан Лінкс

@Andrew: цікава точка зору. Я дещо не згоден з вашим останнім абзацом: якщо базовий клас має функцію, saveяка спирається на конкретну реалізацію функції writeв базовому класі, то мені здається, що або saveпогано закодована, або writeмає бути приватною.
MiniQuark

2
Тільки тому, що запис приватне, не заважає його перекрити. Це ще один аргумент для того, щоб не робити речі віртуальними за замовчуванням. У будь-якому випадку я думав про протилежне - загальне та добре написане виконання замінюється чимось, що має специфічну та несумісну поведінку.
Ендрю Грант

Проголосували за кешування - на будь-якій великій об'єктно-орієнтованій базі коду, якщо ви не дотримуєтесь практики виконання кодової локальності, вашим віртуальним дзвінкам дуже просто спричинити пропуски кешу та сприйняття затримок.
Не впевнений,

І стійла на ікаче може бути справді серйозною: 600 циклів у моїх тестах.
Crashworks

9

Це залежить. :) (Ти очікував чогось іншого?)

Після того, як клас отримує віртуальну функцію, він більше не може бути типом даних POD (він, можливо, раніше не був, і в цьому випадку це не змінить), і це робить неможливим цілий спектр оптимізацій.

std :: copy () для простих типів POD може вдатися до простої процедури memcpy, але з типами, що не мають POD, потрібно обробляти більш ретельно.

Побудова стає набагато повільнішою, оскільки vtable має бути ініціалізований. У гіршому випадку різниця в продуктивності між типами даних POD та POD-POD може бути значною.

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

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

Однак продуктивність не повинна бути вашим основним фактором тут. Зробити все віртуальним не є ідеальним рішенням з інших причин.

Якщо дозволити все перекрити у похідних класах, це набагато складніше підтримувати інваріанти класів. Як клас гарантує, що він залишається в послідовному стані, коли будь-який із його методів можна було переосмислити в будь-який час?

Зробити все віртуальним може усунути кілька потенційних помилок, але він також вводить нові.


7

Якщо вам потрібна функціональність віртуальної відправки, вам доведеться заплатити ціну. Перевага C ++ полягає в тому, що ви можете використовувати дуже ефективну реалізацію віртуальної відправки, що надається компілятором, а не можливо неефективну версію, яку ви реалізуєте самостійно.

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


Хороша відповідь, але, ІМО, недостатньо наголошений у другій половині: пиляти себе накладними, якщо вам це не потрібно, це, чесно кажучи, гайки - особливо, коли ви користуєтесь цією мовою, мантра якої "не платите за те, що ви робите" "Не використовую." Зробити все віртуальним за замовчуванням до тих пір, поки хтось не обгрунтує, чому він може / повинен бути невіртуальним - це мерзотна політика.
підкреслити_

5

Віртуальна відправка - це на порядок повільніше, ніж деякі альтернативи - не через опосередкованість настільки, як запобігання вкрапленню. Нижче я проілюструю, що, протиставляючи віртуальну диспетчеризацію з реалізацією, що вбудовує в об'єкти "тип (ідентифікаційний) номер" та використовуючи оператор перемикання для вибору коду, що відповідає типу. Це дозволяє уникнути повного виклику функції - просто виконувати локальний стрибок. Існує потенційна затрата на технічне обслуговування, залежність від перекомпіляції тощо через вимушену локалізацію (в комутаторі) функціонального типу.


ВПРОВАДЖЕННЯ

#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 рази швидший. Звичайно, це характерно для точної перевіреної системи / прапорів компілятора та версії тощо, але загалом є показовою.


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

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


Я задав запитання щодо вашого коду, оскільки у мене є деякі "дивні" результати за допомогою g++/ clangі -lrt. Я вважав, що це варто згадати тут для майбутніх читачів.
Холт

@Holt: гарне запитання з огляду на містичні результати! Я детальніше ознайомлюсь через кілька днів, якщо отримаю половину шансів. Ура.
Тоні Делрой

3

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

Найбільше, від чого ти відмовляєшся, - це можливі оптимізації за рахунок вбудовування. Вони можуть бути особливо гарними, якщо функція викликається постійними параметрами. Це рідко має реальну зміну, але в кількох випадках це може бути величезним.


Щодо оптимізації:
Важливо знати та враховувати відносну вартість конструкцій вашої мови. Велика нотація O - це половина історії - як масштаби вашої програми . Інша половина - постійний фактор перед нею.

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


Надуманий приклад: порожній віртуальний деструктор на масиві з мільйона маленьких елементів може орати через щонайменше 4 Мб даних, обробляючи ваш кеш. Якщо цей деструктор можна буде відкреслити, дані не будуть торкатися.

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


2

Хоча всі інші вірні щодо продуктивності віртуальних методів і подібних, я думаю, що справжня проблема полягає в тому, чи знає команда про визначення віртуального ключового слова в 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 () і тому, що A's Foo є віртуальним, так і B.

Тепер розглянемо випадок, коли Fo's B є віртуальним, а A - ні. Який тоді вихід? У цьому випадку вихід є

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

Віртуальне ключове слово працює вниз в ієрархії, а не вгору. Це ніколи не робить методи базового класу віртуальними. Перший раз, коли віртуальний метод зустрічається в ієрархії, це коли починається поліморфізм. Існує не спосіб, як для пізніших класів зробити попередні класи віртуальними методами.

Не забувайте, що віртуальні методи означають, що цей клас надає майбутнім класам можливість змінювати / змінювати деякі з їх поведінки.

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

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


Привіт Томмі, дякую за підручник. Помилка у нас була через відсутність ключового слова "віртуальний" у методі базового класу. До речі, я кажу, що зробіть всі функції віртуальними (не навпаки), тоді, коли явно це не потрібно, видаліть "віртуальне" ключове слово.
MiniQuark

@MiniQuark: Томмі Хуй каже, що якщо зробити всі функції віртуальними, програміст може в кінцевому підсумку видалити ключове слово у похідному класі, не розуміючи, що це не впливає. Вам знадобиться якийсь спосіб переконатися, що видалення віртуального ключового слова завжди відбувається в базовому класі.
М. Дадлі

1

Залежно від вашої платформи, накладні витрати на віртуальний дзвінок можуть бути дуже небажаними. Декларуючи кожну функцію віртуальною, ви по суті називаєте їх за допомогою вказівника функції. Принаймні, це додаткова заниженість, але на деяких платформах КПП вона буде використовувати мікрокодування або іншим чином повільні інструкції для цього.

Я б рекомендував проти вашої пропозиції з цієї причини, але якщо це допоможе вам уникнути помилок, можливо, це варто скасувати. Не можу не стверджувати, що, мабуть, має бути якесь середнє місце, яке варто знайти.


-1

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

Але я не думаю, що ви переживаєте, що у забави (int a, int b) є пара додаткових інструкцій "push" порівняно з fun (). Тож не турбуйтеся і про віртуалі, поки ви не опинитесь в особливій ситуації і не переконаєтесь, що це насправді призводить до проблем.

PS Якщо у вас є віртуальний метод, переконайтеся, що у вас є віртуальний деструктор. Таким чином ви уникнете можливих проблем


У відповідь на коментарі "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 у вашому коді? Ви думаєте про конвеєр процесора, коли ви додаєте додаткові, якщо / else ;-) Крім того, ви ніколи не знаєте, на якому процесорі буде працювати код ... Звичайний компілятор може генерувати код, більш оптимальний для одного процесора і менш оптимальний для іншого ( Intel Компілятор C ++ )


2
додатковий асм може просто викликати помилку сторінки (цього не було б для невіртуальних функцій) - я думаю, ви сильно спростите проблему.
xtofl

2
+1 до коментаря xtofl Віртуальні функції вводять непряму, яка вводить "бульбашки" трубопроводу і впливає на кешування поведінку.
Том

1
Призначати що-небудь у режимі налагодження безглуздо. MSVC робить дуже повільним код в режимі налагодження, і накладні петлі, ймовірно, приховують більшу частину різниці. Якщо ви прагнете до високої продуктивності, так, ви повинні подумати про мінімізацію, якщо / else гілки у швидкому шляху. Див. Agner.org/optimize для отримання додаткової інформації про оптимізацію продуктивності x86 низького рівня. (Також деякі інші посилання у вікі тегів x86
Пітер Кордес

1
@Tom: Ключовим моментом тут є те, що невіртуючі функції можуть вбудовуватися, але віртуальні не можуть (якщо компілятор не може девіартуалізувати, наприклад, якщо ви використовували finalпереопределення і у вас є вказівник на похідний тип, а не на базовий тип ). Цей тест викликав одну і ту ж віртуальну функцію кожного разу, тому він передбачив ідеально; ніяких бульбашок трубопроводу, крім обмеженої callпропускної здатності. А це непряме callможе бути ще пару упп. Прогнозування галузей добре працює навіть для непрямих гілок, особливо якщо вони завжди в одному місці призначення.
Пітер Кордес

Це потрапляє у загальну пастку мікротехнічних показників: це швидко виглядає, коли передбачувачі гілок гарячі і більше нічого не відбувається. Непередбачувані накладні витрати вищі для непрямих, callніж для прямих call. (І так, звичайні callінструкції також потребують прогнозування. Етап вилучення повинен знати наступну адресу, яку потрібно отримати до того, як цей блок буде розшифрований, тому він повинен передбачити наступний блок вибору на основі поточної адреси блоку, а не адреси інструкції. Також як передбачити, де в цьому блоці є інструкція з гілки ...)
Пітер Кордес
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.