Віртуальні функції та продуктивність - C ++


125

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


Відповідно до моєї відповіді, я пропоную закрити це як дублікат stackoverflow.com/questions/113830
Suma


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

Відповіді:


90

Хорошим правилом є:

Це не проблема продуктивності, поки ви не зможете це довести.

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

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


А як щодо чистих віртуальних функцій? Чи впливають вони будь-яким чином на продуктивність? Цікаво, як здається, що вони існують просто для примусового виконання.
thomthom

2
@thomthom: Правильно, немає різниці в продуктивності між чистими віртуальними та звичайними віртуальними функціями.
Грег Хьюгілл

168

Ваше запитання мене викликало цікавість, тому я пішов вперед і провів кілька таймінгів на процесорі PowerPC 3GHz для замовлення, з яким ми працюємо. Тест, який я проводив, полягав у тому, щоб скласти простий 4d векторний клас з функціями get / set

class TestVec 
{
    float x,y,z,w; 
public:
    float GetX() { return x; }
    float SetX(float to) { return x=to; }  // and so on for the other three 
}

Потім я встановив три масиви, кожен з яких містить 1024 цих векторів (достатньо малий, щоб вміститися в L1) і провів цикл, який додав їх один до одного (Ax = Bx + Cx) 1000 разів. Я побіг це з функціями , визначеними в якості inline, virtualі регулярні виклики функцій. Ось результати:

  • inline: 8 мс (0,65 за один дзвінок)
  • прямий: 68 мс (5,53 с за дзвінок)
  • віртуальний: 160 мс (13 нс на дзвінок)

Отже, у цьому випадку (де все вміщується в кеші) виклики віртуальних функцій були приблизно в 20 разів повільніше, ніж вбудовані дзвінки. Але що це насправді означає? Кожна поїздка через цикл викликала саме 3 * 4 * 1024 = 12,288функціональні виклики (1024 вектора рази чотири компоненти, три виклики на додавання), тому ці часи представляють 1000 * 12,288 = 12,288,000функціональні виклики. Віртуальний цикл займав 92 мс довше, ніж прямий цикл, тому додаткові накладні витрати на виклик становили 7 наносекунд на функцію.

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

Дивіться також: порівняння створеної збірки.


Але якщо їх називають кілька разів, вони часто можуть бути дешевшими, ніж коли їх називають лише один раз. Дивіться мій невідповідний блог: phresnel.org/blog , публікації під назвою "Віртуальні функції, які вважаються не шкідливими", але, звичайно, це залежить від складності ваших кодових шляхів
Себастьян Мах,

22
Мій тест вимірює невеликий набір віртуальних функцій, викликаних повторно. У вашому дописі на блозі передбачається, що вартість часу коду можна виміряти підрахунком операцій, але це не завжди відповідає дійсності; Основна вартість vfunc для сучасних процесорів - це бульбашка трубопроводу, спричинена неправильним прогнозом гілки.
Crashworks

10
це було б чудовим орієнтиром для gcc LTO (оптимізація часу зв'язку); спробуйте скомпілювати це ще раз з увімкненою lto: gcc.gnu.org/wiki/LinkTimeOptimization і подивіться, що станеться з фактором 20x
lurscher

1
Якщо клас має одну віртуальну та одну вбудовану функцію, чи вплине також і на ефективність невіртуального методу? Просто за характером того, що клас є віртуальним?
thomthom

4
@thomthom Ні, віртуальний / невіртуальний атрибут per-function. Функцію потрібно визначати лише через vtable, якщо вона позначена як віртуальна або якщо вона перекриває базовий клас, який має її як віртуальний. Ви часто бачите класи, які мають групу віртуальних функцій для публічного інтерфейсу, а потім безліч вбудованих аксесуарів тощо. (Технічно це специфічна реалізація, і компілятор може використовувати віртуальні понтери навіть для функцій, позначених "вбудований", але людина, яка написала такий компілятор, була б божевільною.)
Crashworks

42

Коли Objective-C (де всі методи є віртуальними) є основною мовою для iPhone, а чудернацька Java - основна мова для Android, я думаю, що цілком безпечно використовувати віртуальні функції C ++ на наших двоядерних вежах 3 ГГц.


4
Я не впевнений, що iPhone є хорошим прикладом коду виконавця: youtube.com/watch?v=Pdk2cJpSXLg
Crashworks

13
@Crashworks: iPhone взагалі не є прикладом коду. Це приклад апаратного забезпечення - конкретно повільного обладнання , що я тут робив. Якщо ці "повільні" мови, як відомо, досить хороші для обладнання з недостатнім рівнем живлення, віртуальні функції не стануть величезною проблемою.
Чак

52
IPhone працює на процесорі ARM. Процесори ARM, які використовуються для iOS, розроблені для низького МГц та низького енергоспоживання. Немає кремнію для передбачення гілок на процесорі, і тому відсутність накладних витрат від прогнозування гілок у віртуальних викликів функцій. Крім того, МГц для iOS обладнання недостатньо низький, щоб помилка кешу не затримувала процесор протягом 300 тактових циклів, поки він отримує дані з оперативної пам'яті. Пропуски кешу менш важливі при низьких МГц. Коротше кажучи, від використання віртуальних функцій на пристроях iOS немає накладних витрат, але це проблема з обладнанням і не стосується настільних процесорів.
Припиненнядержави

4
Як давно програміст Java, щойно перейшов у C ++, я хочу додати, що компілятор JIT Java та оптимізатор виконання часу має можливість компілювати, прогнозувати та навіть вбудовувати деякі функції під час виконання після заздалегідь заданої кількості циклів. Однак я не впевнений, чи є у C ++ така функція під час компіляції та зв’язку, оскільки їй не вистачає схеми виклику виконання. Таким чином, в C ++ нам може бути потрібно бути обережнішими.
Алекс Суо

@AlexSuo Я не впевнений у твоїй точці? Під час компіляції C ++, звичайно, не може оптимізуватись залежно від того, що може статися під час виконання, тому передбачення тощо повинно здійснюватися самим процесором ... але хороші компілятори C ++ (за вказівками на інструкцію) йдуть на велику довжину, щоб оптимізувати функції та циклічно задовго до цього час виконання.
підкреслюйте_d

34

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

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

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

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


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

5
@Qwertie Я не вважаю, що це потрібно правдою. Тіло циклу (якщо більший за кеш L1) міг би "відступити" vtable pointer, функцію покажчика та подальшу ітерацію, доведеться чекати доступу до кешу L2 (або більше) на кожній ітерації
Ghita

30

З сторінки 44 посібника "Оптимізація програмного забезпечення в C ++" Agner Fog :

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


Дякуємо за цю довідку. Посібники з оптимізації Agner Fog є золотим стандартом для оптимального використання обладнання.
Арто Бендікен

На основі мого спогаду та швидкого пошуку - stackoverflow.com/questions/17061967/c-switch-and-jump-tables - я сумніваюся, що це завжди так switch. З абсолютно довільними caseзначеннями, звичайно. Але якщо всі cases послідовні, компілятор, можливо, зміг би оптимізувати це у стрибкову таблицю (ах, це нагадує мені про старі добрі дні Z80), які повинні бути (для кращого терміну) постійним часом. Не те, що я рекомендую спробувати замінити vfuncs на switch, що смішно. ;)
підкреслити_10

7

абсолютно. Це було проблемою назад, коли комп'ютери працювали зі швидкістю 100 МГц, оскільки кожен виклик методу вимагав пошуку в vtable, перш ніж його викликали. Але сьогодні .. на 3 ГГц процесорі, який має кеш 1-го рівня з більшою пам’яттю, ніж у мого першого комп’ютера? Зовсім ні. Виділення пам'яті з основної оперативної пам’яті обійдеться вам більше часу, ніж якби всі ваші функції були віртуальними.

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

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

PS думають про інші "прості у використанні" мови - всі їхні методи віртуозні під обкладинками, і вони сьогодні не сканують.


4
Ну і навіть сьогодні уникати функціональних дзвінків важливо для програм із високим рівнем доступу. Різниця полягає в тому, що сьогоднішні компілятори надійно вбудовують невеликі функції, тому ми не зазнаємо швидкості покарання за написання невеликих функцій. Що стосується віртуальних функцій, розумні процесори можуть робити інтелектуальне передбачення гілок на них. Те, що старі комп’ютери були повільнішими, я думаю, насправді це не проблема - так, вони були набагато повільнішими, але тоді ми це знали, тому ми давали їм набагато менші навантаження. У 1992 році, якщо ми грали в MP3, ми знали, що, можливо, доведеться присвятити цьому завдання більше половини процесора.
Qwertie

6
mp3 датується 1995 роком. У 92 році нас ледве було 386, жодного способу вони не могли відтворити mp3, і 50% процесорного часу передбачає гарну багатозадачну ОС, режим роботи в режимі очікування та попереджувальний планувальник. Нічого цього на той час не існувало на споживчому ринку. це було 100% з моменту, коли влада була ВКЛ, кінець історії.
v.oddou

7

Окрім часу виконання, є ще один критерій ефективності. Vtable також займає простір пам’яті, а в деяких випадках цього можна уникнути: ATL використовує час компіляції « імітацію динамічного зв’язування » із шаблонамиотримати ефект "статичного поліморфізму", який важко пояснити; ви в основному передаєте похідний клас як параметр шаблону базового класу, тому під час компіляції базовий клас "знає", що є його похідним класом у кожному екземплярі. Не дозволить вам зберігати кілька різних похідних класів у колекції базових типів (це поліморфізм під час виконання), але з статичного сенсу, якщо ви хочете створити клас Y, такий же, як попередній клас шаблону X, який має гачки для такого роду переосмислення, вам просто потрібно переосмислити методи, які вас цікавлять, і тоді ви отримаєте базові методи класу X без необхідності мати vtable.

У класах з великими слідами пам’яті вартість одного vtable-покажчика не дуже велика, але деякі класи ATL в COM дуже малі, і варто втратити vtable, якщо випадок поліморфізму під час виконання ніколи не відбудеться.

Дивіться також це інше запитання про те .

До речі, ось у публікації я виявив, що говорить про аспекти продуктивності процесора.



4

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


1
Пов'язана стаття не вважає дуже важливою частиною віртуального виклику, і це можливе неправильне передбачення.
Сума

4

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

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


2
виклик чого-небудь у вузькому циклі, ймовірно, збереже весь цей код і дані гарячими в кеші ...
Грег Роджерс,

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

3

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

Це добре проілюстровано тестом, різниця у часі ~ 700% (!):

#include <time.h>

class Direct
{
public:
    int Perform(int &ia) { return ++ia; }
};

class AbstrBase
{
public:
    virtual int Perform(int &ia)=0;
};

class Derived: public AbstrBase
{
public:
    virtual int Perform(int &ia) { return ++ia; }
};


int main(int argc, char* argv[])
{
    Direct *pdir, dir;
    pdir = &dir;

    int ia=0;
    double start = clock();
    while( pdir->Perform(ia) );
    double end = clock();
    printf( "Direct %.3f, ia=%d\n", (end-start)/CLOCKS_PER_SEC, ia );

    Derived drv;
    AbstrBase *ab = &drv;

    ia=0;
    start = clock();
    while( ab->Perform(ia) );
    end = clock();
    printf( "Virtual: %.3f, ia=%d\n", (end-start)/CLOCKS_PER_SEC, ia );

    return 0;
}

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

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


4
Виклик віртуальної функції в порівнянні з ++ia. І що?
Бо Персон

2

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

Невже хіт продуктивності буде помітний на сучасному ноутбуці / настільному ПК / планшеті ... напевно, ні! Однак у деяких випадках із вбудованими системами ефективність може бути рушійним фактором неефективності вашого коду, особливо якщо віртуальна функція знову і знову викликається в циклі.

Ось деякий документ із датою, який аналізує кращі практики C / C ++ у контексті вбудованих систем: http://www.open-std.org/jtc1/sc22/wg21/docs/ESC_Boston_01_304_paper.pdf

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


2

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


1

Варто зазначити, що це:

boolean contains(A element) {
    for (A current: this)
        if (element.equals(current))
            return true;
    return false;
}

може бути швидше, ніж це:

boolean contains(A element) {
    for (A current: this)
        if (current.equals(equals))
            return true;
    return false;
}

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

Я кажу "може", тому що це залежить від компілятора, кеша тощо.


0

Штраф за ефективність використання віртуальних функцій ніколи не може перевершити переваги, які ви отримуєте на рівні дизайну. Нібито виклик віртуальної функції був би на 25% менш ефективним, ніж прямий виклик статичної функції. Це тому, що існує рівень непрямості через VMT. Однак час, необхідний для здійснення дзвінка, як правило, дуже невеликий порівняно з часом, який затрачено на фактичне виконання вашої функції, тому загальна вартість продуктивності буде нікчемною, особливо при поточній продуктивності обладнання. Крім того, компілятор іноді може оптимізувати і побачити, що не потрібен віртуальний дзвінок, і скомпілювати його в статичний виклик. Тож не хвилюйтеся використовувати віртуальні функції та абстрактні класи стільки, скільки вам потрібно.


2
ніколи, незалежно від того, наскільки маленький цільовий комп'ютер?
zumalifeguard

Я, можливо, погодився б, якби Ви це сформулювали так, як The performance penalty of using virtual functions can sometimes be so insignificant that it is completely outweighed by the advantages you get at the design level.говорить Ключова різниця sometimes, ні never.
підкреслюй_

-1

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

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

// g++ -std=c++0x -o perf perf.cpp -lrt
#include <typeinfo>    // typeid
#include <cstdio>      // printf
#include <cstdlib>     // atoll
#include <ctime>       // clock_gettime

struct Virtual { virtual int call() { return 42; } }; 
struct Inline { inline int call() { return 42; } }; 
struct Normal { int call(); };
int Normal::call() { return 42; }

template<typename T>
void test(unsigned long long count) {
    std::printf("Timing function calls of '%s' %llu times ...\n", typeid(T).name(), count);

    timespec t0, t1;
    clock_gettime(CLOCK_REALTIME, &t0);

    T test;
    while (count--) test.call();

    clock_gettime(CLOCK_REALTIME, &t1);
    t1.tv_sec -= t0.tv_sec;
    t1.tv_nsec = t1.tv_nsec > t0.tv_nsec
        ? t1.tv_nsec - t0.tv_nsec
        : 1000000000lu - t0.tv_nsec;

    std::printf(" -- result: %d sec %ld nsec\n", t1.tv_sec, t1.tv_nsec);
}

template<typename T, typename Ua, typename... Un>
void test(unsigned long long count) {
    test<T>(count);
    test<Ua, Un...>(count);
}

int main(int argc, const char* argv[]) {
    test<Inline, Normal, Virtual>(argc == 2 ? atoll(argv[1]) : 10000000000llu);
    return 0;
}

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


12
Я думаю, що цілком ймовірно, що ваш компілятор може сказати, що виклик віртуальної функції у вашому коді може викликати лише Virtual :: call. У такому випадку він може просто накреслити його. Також нічого не заважає компілятору вкладати звичайний :: виклик, навіть якщо ви цього не просили. Тож я думаю, що цілком можливо, що ви отримуєте однакові рази для трьох операцій, оскільки компілятор генерує для них однаковий код.
Bjarke H. Roune
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.