Чому в C ++ чому і як віртуальні функції повільніші?


38

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

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


5
Пошук правильного виклику методу з vtable , очевидно, займе більше часу, ніж безпосередній виклик методу, оскільки тут ще багато чого робити. Скільки часу чи важливий цей додатковий час у контексті вашої власної програми - це інше питання. en.wikipedia.org/wiki/Virtual_method_table
Роберт Харві

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

7
Часто буває не так, що самі віртуальні дзвінки повільні, а компілятор не має можливості їх вбудовувати.
Кевін Хсу

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

7
Навіть людина, яка може прочитати код складання, не може точно передбачити його накладні витрати в реальному виконанні процесора. Виробники процесорів на базі настільних комп’ютерів інвестували в десятиліття досліджень не тільки галузеве передбачення, але й цінують прогнозування та спекулятивне виконання з головної причини маскування затримки віртуальних функцій. Чому? Тому що настільні ОС та програмне забезпечення їх багато використовують. (Я б не сказав того ж про мобільні процесори.)
rwong

Відповіді:


55

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

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

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

Редагувати: Різниця в інструкціях про дзвінки описана в інших відповідях. В основному, код для статичного ("нормального") дзвінка:

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

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

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

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


6
@ JörgWMittag - все це інтерпретатор, і вони все ще повільніше, ніж двійковий код, сформований компіляторами C ++
Сем

13
@ JörgWMittag Ці оптимізації в першу чергу існують, щоб зробити індикацію / пізнє прив'язування (майже) безкоштовним, коли це не потрібно , тому що в цих мовах кожен виклик технічно обмежений. Якщо ви дійсно за короткий час викликаєте багато різних віртуальних методів з одного місця, ці оптимізації не допомагають і не зашкоджують (створюйте безліч кодів нанівець). Хлопці C ++ не дуже зацікавлені в цих оптимізаціях, оскільки вони знаходяться в зовсім іншій ситуації ...

10
@ JörgWMittag ... Хлопці C ++ не дуже зацікавлені в цих оптимізаціях, оскільки вони знаходяться в дуже різній ситуації: VOT-спосіб, складений AOT, вже досить швидкий, дуже мало дзвінків насправді є віртуальними, багато випадків поліморфізму є ранніми, пов'язаний (через шаблони) і, отже, підлягає оптимізації AOT. Нарешті, для адаптації цих оптимізацій (замість того, щоб просто спекулювати під час компіляції) потрібно генерування коду під час виконання, який вводить тонни головного болю. Компілятори JIT вже вирішили ці проблеми з інших причин, тому вони не проти, але компілятори AOT хочуть цього уникнути.

3
чудова відповідь, +1. Однак слід зазначити одне, що іноді результати розгалуження відомі під час компіляції, наприклад, коли ви пишете рамкові класи, які повинні підтримувати різні способи використання, але як тільки код програми взаємодіє з цими класами, конкретне використання вже відомо. У цьому випадку альтернативою віртуальним функціям можуть бути шаблони C ++. Хорошим прикладом може бути CRTP, який імітує поведінку віртуальних функцій без будь-яких vtables: en.wikipedia.org/wiki/Curiously_recurring_template_pattern
DXM

3
@James У вас є пункт. Що я намагався сказати, це: будь-яка непрямість має однакові проблеми, вона не має нічого конкретного virtual.

23

Ось кілька фактично розібраних кодів з виклику віртуальної функції та невіртуального виклику відповідно:

mov    -0x8(%rbp),%rax
mov    (%rax),%rax
mov    (%rax),%rax
callq  *%rax

callq  0x4007aa

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

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

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


1
Слід розуміти, що пошук стійких пошукових запитів та непрямий виклик майже у всіх випадках матимуть незначний вплив на загальний час роботи виклику методу.
Джон Р. Стром

12
@ JohnR.Strohm Незначною мірою одна людина - вузьке місце у іншого чоловіка
Джеймс

1
-0x8(%rbp). о мій ... що синтаксис AT&T
Абікс

" три додаткові інструкції " немає, лише дві: завантаження vptr та завантаження вказівника функції
curiousguy

@curiousguy насправді це три додаткові інструкції. Ви забули, що віртуальний метод завжди викликається вказівником , тому вам потрібно спочатку завантажити вказівник у регістр. Підводячи підсумок, самим першим кроком є ​​завантаження адреси, яку змінна вказівника містить у регістрі% rax, потім відповідно до адреси в реєстрі, завантажте vtpr на цю адресу, щоб зареєструвати% rax, а потім відповідно до цієї адреси в зареєструйте, завантажте адресу методу, який потрібно викликати, у% rax, тоді callq *% rax !.
Ґаб 是 好人

18

Повільніше, ніж що ?

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

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

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

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

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


4
"Повільніше, ніж що?" - якщо ви робите метод віртуальним, якого не повинно бути, у вас є досить хороший матеріал для порівняння.
tdammers

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

12

тому що віртуальний дзвінок еквівалентний

res_t (*foo)(arg_t);
foo = (obj->vtable[foo_offset]);
foo(obj,args)

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

це також дозволяє вбудувати функцію (з усіма наслідками оптимізації)

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.