Загалом, чи варто використовувати віртуальні функції, щоб уникнути розгалуження?


21

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

  • інструкція проти пропуску кеш даних
  • оптимізаційний бар'єр

Якщо ви дивитесь на щось подібне:

if (x==1) {
   p->do1();
}
else if (x==2) {
   p->do2();
}
else if (x==3) {
   p->do3();
}
...

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

p->do()

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

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


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

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

1
Питання: "Але, загалом, наскільки дорогі віртуальні функції ..." Відповідь: Непряма гілка (wikipedia)
rwong

1
Пам’ятайте, що більшість відповідей засновані на підрахунку кількості інструкцій. Як оптимізатор коду низького рівня, я не довіряю кількості інструкцій; ви повинні довести їх на певній архітектурі процесора - фізично - в експериментальних умовах. Дійсні відповіді на це питання повинні бути емпіричними та експериментальними, а не теоретичними.
rwong

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

Відповіді:


21

Мені хотілося заскочити сюди серед цих вже чудових відповідей і визнати, що я сприйняв некрасивий підхід до того, щоб насправді діяти назад до антитіни зміни поліморфного коду на switchesабо if/elseгілки з вимірюваними вигодами. Але я цього не робив оптом, лише для найбільш критичних шляхів. Це не повинно бути таким чорним і білим.

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

Поліморфне рефакторинг умов

По-перше, варто зрозуміти, чому поліморфізм може бути кращим з аспекту ремонтопридатності, ніж умовне розгалуження ( switchабо купа if/elseтверджень). Основна перевага тут - розширюваність .

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

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

Оптимізаційний бар'єр

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

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

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

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

Оптимізація пам'яті

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

vector<Creature*> creatures;

Примітка: для простоти я unique_ptrтут уникав .

... де Creatureполіморфний базовий тип. У цьому випадку одна з труднощів з поліморфними контейнерами полягає в тому, що вони часто хочуть виділити пам'ять для кожного підтипу окремо / індивідуально (наприклад: використання закидання operator newза замовчуванням для кожної окремої істоти).

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

Часткова девіртуалізація структур даних і циклів

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

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

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

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

vector<Human> humans;               // common case
vector<Creature*> other_creatures;  // additional rare-case creatures

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

vector<Human> humans;               // common case
vector<Creature*> other_creatures;  // additional rare-case creatures
vector<Creature*> creatures;        // contains humans and other creatures

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

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

Часткова девіртуалізація занять

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

switch (obj->type())
{
   case id_common_type:
       static_cast<CommonType*>(obj)->non_virtual_do_something();
       break;
   ...
   default:
       obj->virtual_do_something();
       break;
}

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

Девіртуалізація оптом

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

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

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

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

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

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

Віртуальні функції проти покажчиків функцій

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

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

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

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

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

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

class Functionoid
{
public:
    virtual ~Functionoid() {}
    virtual void operator()() = 0;
};

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

void (*func_ptr)(void* instance_data);

... в ідеалі за безпечним для інтерфейсу інтерфейсом, щоб приховати небезпечні касти в / з void*.

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

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

Висновок

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


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

@RobertBaron: Я ніколи не бачив, щоб віртуальні функції реалізовувалися, як ви сказали (= з ланцюжком пошуку ланцюга через ієрархію класів). Зазвичай компілятори просто генерують "сплющений" vtable для кожного конкретного типу з усіма правильними вказівниками функції, а під час виконання виклик вирішується за допомогою одного прямого пошуку таблиці; за глибокі ієрархії спадкування не сплачується штраф.
Маттео Італія

Маттео, це було пояснення, яке технічне керівництво дало мені багато років тому. Звичайно, це було для c ++, тому він, можливо, враховував наслідки багаторазового успадкування. Дякую, що ви прояснили моє розуміння того, як оптимізовано віртуальний файл.
Роберт Барон

Дякую за хорошу відповідь (+1). Цікаво, наскільки це стосується однаково для std :: visit замість віртуальних функцій.
DaveFar

13

Спостереження:

  • У багатьох випадках віртуальні функції швидші, оскільки пошук vtable є O(1)операцією, а else if()сходи - це O(n)операція. Однак це справедливо лише в тому випадку, якщо розподіл справ є рівним.

  • Для одиничного if() ... elseумовне швидше, оскільки ви зберігаєте виклик функції накладні.

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

  • Якщо ви використовуєте switch()замість else if()драбинок або віртуальних викликів функцій, ваш компілятор може створити ще кращий код: він може зробити гілку до місця, яке шукається з таблиці, але це не виклик функції. Тобто у вас є всі властивості виклику віртуальної функції без усіх накладних викликів функції.

  • Якщо одна набагато частіша за решту, починаючи if() ... elseз цього випадку, ви отримаєте найкращу ефективність: Ви виконаєте одну умовну гілку, яка в більшості випадків правильно передбачена.

  • Ваш компілятор не знає очікуваного розподілу справ і передбачає рівний розподіл.

Оскільки ваш компілятор, ймовірно, має добру евристику щодо того, коли кодувати а switch()як else if()сходи або як пошук таблиці. Я би схильний довіряти його судженням, якщо ви не знаєте, що розподіл справ упереджений.

Отже, моя порада така:

  • Якщо один із випадків заглушує решту за частотою, використовуйте відсортовану else if()драбину.

  • В іншому випадку використовуйте switch()оператор, якщо один із інших методів не зробить ваш код набагато читабельнішим. Будьте впевнені, що ви не купуєте незначний приріст продуктивності зі значно зниженою читабельністю.

  • Якщо ви використовували a switch()і все ще не задоволені продуктивністю, зробіть порівняння, але будьте готові до з'ясування, що це switch()вже найшвидша можливість.


2
Деякі компілятори дозволяють анотаціям повідомляти компілятору, який випадок є більш правдивим, і ці компілятори можуть створювати швидший код до тих пір, поки анотація є правильною.
gnasher729

5
Операція O (1) не обов'язково швидша в реальному часі виконання, ніж O (n) або навіть O (n ^ 20).
whatsisname

2
@whatsisname Тому я сказав "для багатьох випадків". За визначенням O(1)і O(n)існує kтак, що O(n)функція більша, ніж O(1)функція для всіх n >= k. Питання лише в тому, чи є у вас стільки справ. І так, я бачив switch()заяви з такою кількістю випадків, що else if()драбина, безумовно, повільніше, ніж виклик віртуальної функції або завантажена відправка.
cmaster - відновити моніку

Проблема, з якою я маю цю відповідь, є єдиним застереженням від прийняття рішення, заснованого на абсолютно нерелевантному підвищенні продуктивності, приховано десь у наступному до останнього абзацу. Все інше тут робить вигляд , це може бути ідея хороша , щоб прийняти рішення про ifпроти switchпроти віртуальних функцій , заснованих на Perfomance. У вкрай рідкісних випадках це може бути, але в більшості випадків це не так.
Док Браун

7

Загалом, чи варто використовувати віртуальні функції, щоб уникнути розгалуження?

Загалом, так. Переваги для технічного обслуговування є вагомими (тестування на розділення, розділення проблем, покращення модульності та розширюваності).

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

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

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

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

Ви говорите, що хочете, щоб цей розділ працював якомога швидше; Наскільки це швидко? Яка ваша конкретна вимога?

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

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


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

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

5

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

Я написав сценарій Python для випадкового генерування коду C ++ 14 для VM з розміром набору інструкцій, вибраним випадковим чином (хоч і не рівномірно, відбираючи низький діапазон більш щільно) між 1 і 10000. Створений VM завжди мав 128 регістрів і ні ОЗП. Інструкція не є змістовною, і всі мають наступну форму.

inline void
op0004(machine_state& state) noexcept
{
  const auto c = word_t {0xcf2802e8d0baca1dUL};
  const auto r1 = state.registers[58];
  const auto r2 = state.registers[69];
  const auto r3 = ((r1 + c) | r2);
  state.registers[6] = r3;
}

Сценарій також генерує процедури відправки, використовуючи switchоператор ...

inline int
dispatch(machine_state& state, const opcode_t opcode) noexcept
{
  switch (opcode)
  {
  case 0x0000: op0000(state); return 0;
  case 0x0001: op0001(state); return 0;
  // ...
  case 0x247a: op247a(state); return 0;
  case 0x247b: op247b(state); return 0;
  default:
    return -1;  // invalid opcode
  }
}

… І масив функціональних покажчиків.

inline int
dispatch(machine_state& state, const opcode_t opcode) noexcept
{
  typedef void (* func_type)(machine_state&);
  static const func_type table[VM_NUM_INSTRUCTIONS] = {
    op0000,
    op0001,
    // ...
    op247a,
    op247b,
  };
  if (opcode >= VM_NUM_INSTRUCTIONS)
    return -1;  // invalid opcode
  table[opcode](state);
  return 0;
}

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

Для бенчмаркінгу потік оп-кодів генерувався випадковим насінням ( std::random_device) Mersenne twister random engine ( std::mt19937_64).

Код для кожної віртуальної машини було скомпільовано з GCC 5.2.0 з допомогою -DNDEBUG, -O3і -std=c++14перемикачі. По-перше, він був складений за допомогою -fprofile-generateпараметрів та даних профілю, зібраних для моделювання 1000 випадкових інструкцій. Потім код був перекомпільований з -fprofile-useможливістю оптимізації на основі зібраних даних профілю.

Потім ВМ здійснювали (в тому ж процесі) чотири рази протягом 50 000 000 циклів і вимірювали час для кожного пробігу. Перший запуск було відмовлено, щоб усунути ефекти холодного кешу. PRNG не було повторно засіяно між прогонами, щоб вони не виконували однакову послідовність інструкцій.

За допомогою цього налаштування було зібрано 1000 точок даних для кожного розпорядження. Дані були зібрані на чотирьохядерному процесорі AMD A8-6600K APU з кешеним кешем 2048 KiB, 64-бітним GNU / Linux, без графічного робочого столу чи інших програм. Нижче показано графік середнього часу процесора (зі стандартним відхиленням) за інструкцію для кожного VM.

введіть тут опис зображення

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

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


3

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

Ще одна річ, яку слід пам’ятати: якщо ваш критичний шлях включає цикл, це може бути корисним для сортування циклу за місцем відправлення. Очевидно, що це nlogn, тоді як обхід петлі - лише n, але якщо ви збираєтеся пройти багато разів, це може бути вартим того. Сортувавши за пунктом призначення відправлення, ви гарантуєте, що той самий код виконується повторно, зберігаючи його гарячим в ікачі, мінімізуючи пропуски кешу.

Третя стратегія, яку потрібно пам’ятати: якщо ви вирішите відійти від віртуальних функцій / покажчиків функцій на бік стратегій if / switch, вам також може бути хороший сервіс, перейшовши з поліморфних об'єктів на щось на зразок boost :: variant (який також забезпечує перемикання випадок у вигляді абстракції відвідувача). Поліморфні об'єкти повинні зберігатися за базовим вказівником, тому ваші дані є скрізь у кеші. Це легко може мати більший вплив на ваш критичний шлях, ніж вартість віртуального пошуку. Беручи до уваги, що варіант зберігається в рядку як дискримінаційний союз; вона має розмір, що дорівнює найбільшому типу даних (плюс невелика константа). Якщо ваші предмети не сильно відрізняються за розміром, це чудовий спосіб поводження з ними.

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


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

1
Це передбачає додаткові кроки. Сам vtable містить покажчики функцій, тому коли ви переходите до vtable, ви доходили до того ж стану, в якому ви запускалися, з покажчиком функції. Все, перш ніж потрапити в vtable - це додаткова робота. Класи не містять своїх vtables, вони містять вказівники на vtables, і слідування за цим вказівником є ​​додатковою дереференцією. Насправді, іноді є третя дереференція, оскільки поліморфні класи, як правило, утримуються вказівниками базового класу, тому вам доведеться дереференціювати вказівник, щоб отримати адресу vtable (щоб відкинути його ;-)).
Нір Фрідман

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

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

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

2

Чи можу я просто пояснити, чому я думаю, що це проблема XY ? (Ви не самотні, коли запитуєте їх.)

Я припускаю, що ваша реальна мета - заощадити час в цілому, а не просто зрозуміти пункт про кеш-пропуски та віртуальні функції.

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

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

У прикладі, до якого я посилався, спочатку було потрібно 2700 мікросекунд за "роботу". Виправлено низку шести проблем, пройшовши проти годинникової стрілки навколо піци. Перша швидкість прибрала 33% часу. Другий знімав 11%. Але зауважте, друга не була 11% в той час, як її було знайдено, це було 16%, тому що першої проблеми вже не було . Так само третя проблема була збільшена з 7,4% до 13% (майже вдвічі), оскільки перших двох проблем не було.

Зрештою, цей процес збільшення дозволив усунути всі, крім 3,7 мікросекунди. Це 0,14% від початкового часу, або швидкість 730x.

введіть тут опис зображення

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

введіть тут опис зображення

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

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


він уже сказав, що у нього є кілька "критично важливих розділів", які потребують кожної останньої наносекунди виконання. Тож це не відповідь на запитання, яке він задав (навіть якщо це було б чудовою відповіддю на чуже запитання)
gbjbaanb

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

@ gnasher729 Я не знаю, але чому це закінчується "дуже критичними розділами"? Я здогадуюсь, як і slashdot, завжди слід читати зміст, а не лише заголовок!
gbjbaanb

2
@gbjbaanb: Усі кажуть, що у них є "дуже критичні секції". Звідки вони знають? Я не знаю, що щось є критичним, доки я не візьму, скажімо, 10 зразків, і не перегляну його на 2 або більше з них. У такому випадку, якщо виклики методів беруть більше 10 інструкцій, віртуальна функція, напевно, незначна.
Майк Данлаве

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