Поради щодо оптимізації низького рівня C ++ [закрито]


79

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

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


1
Що робить цим питання розвитку гри , а не загальне програмування питання , як ці: stackoverflow.com/search?q=c%2B%2B+optimization
Денні Varod

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

@Smashery Єдина різниця між ними полягає в тому, що ігрове програмування може вимагати конкретних оптимізацій рівня графічного двигуна або оптимізації шейдерних кодерів, частина C ++ однакова.
Danny Varod

@Danny - Правда, деякі питання будуть "більш" актуальними на тому чи іншому сайті; але я не хотів би відмовлятись від будь-яких відповідних питань лише тому, що їх також можна задати на іншому веб-сайті.
Smashery

Відповіді:


76

Оптимізуйте ваш макет даних! (Це стосується більшої кількості мов, ніж лише C ++)

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

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

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

Дійсно процесор швидкий, а компілятор хороший. Не дуже багато можна зробити, скориставшись меншими та швидшими інструкціями. Злагодженість кешу - це місце (це випадкова стаття I Googled - вона містить хороший приклад отримання когерентності кеш-пам'яті для алгоритму, який не просто запускає дані лінійно).


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

9
Дивіться також чудову презентацію « Підводних каменів» об'єктно-орієнтованого програмування (Sony R&D) ( research.scee.net/files/presentations/gcapaustralia09/… ) - і приємні, але захоплюючі статті CellPerformance Майка Актона ( cellperformance.beyond3d.com/articles/ index.html ). Ігри Ноеля Льопіса з блогу також часто торкаються цієї теми ( gamesfromwithin.com ). Я не можу рекомендувати слайди Підводних каменів достатньо ...
орендуюсь

2
Я б просто попередив про "зробити дані для кожної ітерації якомога меншими та максимально зближеними в пам'яті" . Доступ до неприєднаних даних може зробити все повільніше; в такому випадку накладка дасть кращі показники. Порядок даних є важливим теж, як і впорядковані дані можуть привести до зменшення запалення. Скотт Мейерс може пояснити це краще, ніж я можу :)
Джонатан Коннелл

+1 до презентації Sony. Я читав це раніше і справді має сенс оптимізувати дані на рівні платформи з урахуванням розділення даних на шматки та їх правильного вирівнювання.
ChrisC

84

Дуже, дуже низький рівень, але такий, який може стати у нагоді:

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

if(__builtin_expect(entity->extremely_unlikely_flag, 0)) {
  // code that is rarely run
}

Я бачив 10-20% прискорення при правильному використанні цього.


1
Я би голосував двічі, якби міг.
tenpn

10
+1, ядро ​​Linux широко використовує це для мікрооптимізацій у коді планувальника, і це робить суттєву різницю в певних кодових шляхах.
greyfade

2
На жаль, здається, що у Visual Studio немає хорошого еквівалента. stackoverflow.com/questions/1440570 / ...
mmyers

1
Отже, на якій частоті очікуване значення зазвичай повинно бути правильним для отримання продуктивності? 49/50 разів? Або 999999/1000000 разів?
Дуглас

36

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

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

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

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

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

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

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

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

А потім профілюйте його ще раз.


31

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

Другий крок: Подивіться на створену збірку. C ++ приносить до таблиці багато неявного генерації коду. Іноді вона підкрадається до вас, не знаючи.

Але якщо припустити, що це дійсно час педалі до металу: профіль. Серйозно. Випадково застосовувати "фокуси на виконання" - це настільки ж ймовірно, як і допомогти.

Тоді все залежить від того, які у вас вузькі місця.

кеш даних пропускає => оптимізуйте ваш макет даних. Ось хороший вихідний пункт: http://gamesfromwithin.com/data-oriented-design

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

Інші поширені раковини C ++:

  • Надмірний розподіл / розселення. Якщо це важливо для роботи, не заходьте до часу виконання. Колись.
  • Копія конструкції. Уникайте, де тільки можете. Якщо це може бути посилання const, зробіть його одним.

Все вищезазначене відразу очевидно, коли ви дивитесь на збірку, тому дивіться вище;)


19

Видаліть зайві гілки

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

Архітектура PowerPC (PS3 / x360) пропонує з плаваючою комою виберіть команду, fsel. Це можна використовувати на місці гілки, якщо блоки є простими призначеннями:

float result = 0;
if (foo > bar) { result = 2.0f; }
else { result = 1.0f; }

Стає:

float result = fsel(foo-bar, 2.0f, 1.0f);

Коли перший параметр більше або дорівнює 0, повертається другий параметр, третій.

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

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

Ось додаткова інформація про розгалуження та фсель:

http://assemblyrequired.crashworks.org/tag/intrinsics/


float результат = (foo> bar)? 2.f: 1.f
лицар666

3
@ knight666: Це все одно створить гілку в будь-якій точці, що зробила б "long" Я говорю це так, тому що в ARM принаймні невеликі такі послідовності можуть бути реалізовані за допомогою умовних інструкцій, які не потребують розгалуження.
chrisbtoo

1
@ knight666, якщо пощастить, компілятор може перетворити це на фсель, але це не точно. FWIW, я б зазвичай писав цей фрагмент третинним оператором, а потім пізніше оптимізував би fsel, якщо профілер погодився.
tenpn

На IA32 ви отримали CMOVcc замість цього.
Skizz

Дивіться також blueraja.com/blog/285/… (зауважте, що в цьому випадку, якщо компілятор є корисним, він повинен бути в змозі оптимізувати це сам, тож це не те, про що зазвичай потрібно турбуватися)
BlueRaja - Danny Pflughoeft

16

Уникайте доступу будь-якої пам'яті та особливо випадкових за будь-яку ціну.

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

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


13

Використовуйте внутрішню компіляцію компілятора.

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

Ось посилання на Visual Studio , а ось ось на GCC


11

Видаліть непотрібні виклики віртуальних функцій

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

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

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


9

Мій основний принцип: не робіть нічого, що не потрібно .

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

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

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


2
Це питання передбачає, що ви вже зробили всі структурні речі, які можете.
tenpn

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

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

9

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


6

Мінімізуйте ланцюги залежностей, щоб краще використовувати базовий процесор.

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

Приклад:

float *data = ...;
int length = ...;

// Slow version
float total = 0.0f;
int i;
for (i=0; i < length; i++)
{
  total += data[i]
}

// Fast version
float total1, total2, total3, total4;
for (i=0; i < length-3; i += 4)
{
  total1 += data[i];
  total2 += data[i+1];
  total3 += data[i+2];
  total4 += data[i+3];
}
for (; i < length; i++)
{
  total += data[i]
}
total += (total1 + total2) + (total3 + total4);

4

Не забувайте про свій компілятор - якщо ви використовуєте gcc в Intel, ви можете легко отримати підвищення продуктивності, наприклад, перейшовши на компілятор Intel C / C ++. Якщо ви орієнтовані на платформу ARM, перегляньте комерційний компілятор ARM. Якщо ви перебуваєте на iPhone, Apple просто дозволила використовувати Clang, починаючи з iOS 4.0 SDK.

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

Моя найкраща порада - ігнорувати низькорівневі оптимізації та зосереджуватися на оптимізаціях вищого рівня. Компілятор і процесор не можуть змінити ваш алгоритм з алгоритму O (n ^ 2) на O (1), незалежно від того, наскільки вони хороші. Це вимагає від вас, щоб точно подивитися, що ви намагаєтеся зробити, і знайти кращий спосіб зробити це. Нехай компілятор і процесор турбуються про низький рівень, і ви зосередитесь на середньому та високому рівнях.


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

1
Дивіться мою відповідь re: O (log n). Крім того, якщо ви шукаєте півмільйсекунди, можливо, вам доведеться подивитися на вищий рівень. Це 3% часу вашого кадру!
Рейчел Блюм

4

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

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

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

Дивіться також: http://cellperformance.beyond3d.com/articles/2006/05/demystifying-the-restrict-keyword.html


2

Оцініть все!

Чим більше інформації ви дасте компілятору щодо даних, тим кращі оптимізації (принаймні, на мій досвід).

void foo(Bar * x) {...;}

стає;

void foo(const Bar * const x) {...;}

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

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


І ваш приятель коду буде вас любити!
tenpn

4
constне покращує оптимізацію компілятора. Правда, компілятор може генерувати кращий код, якщо знає, що змінна не зміниться, але constне дає достатньо міцної гарантії.
deft_code

3
Ні. "обмежити" набагато корисніше, ніж "const". Див gamedev.stackexchange.com/questions/853 / ...
Justicle

+1 ppl кажучи, що допомогти const cant невірно ... infoq.com/presentations/kixeye-scalability
NoSenseEtAl

2

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

Якщо припустити, що зроблено….

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

Уникайте пропусків кеша. Пакетна обробка стільки, скільки ви можете. Уникайте віртуальних функцій та інших непрямих.

Зрештою, виміряйте все. Правила постійно змінюються. Те, що раніше пришвидшувало код 3 роки тому, зараз уповільнює його. Гарний приклад - «використовувати подвійні математичні функції замість плаваючих версій». Я б не зрозумів цього, якби не прочитав.

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

Відредаговано, щоб пояснити, чому немає ініціалізації за замовчуванням. Багато коду говорить: Vector3 bla; bla = DoSomething ();

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

Vector3 bla (noInit); bla = doSomething ();


/ Не / ініціалізуйте своїх членів у конструкторах? Як це допомагає?
tenpn

Дивіться відредаговану публікацію. Не вмістився у поле для коментарів.
Кай

const Vector3 = doSomething()? Тоді оптимізація зворотного значення може почати і, можливо, сформувати завдання або два.
tenpn

1

Зменшити булеву оцінку вираження

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

if ((foo && bar) || blah) { ... } 

Стає:

if ((foo & bar) | blah) { ... }

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

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

Великий мінус полягає в тому, що ви втрачаєте ледачу оцінку - оцінюється весь блок, тому ви не можете цього зробити foo != NULL & foo->dereference(). Через це можна сперечатися, що це важко підтримувати, і тому компроміс може бути занадто великим.


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

Я майже повністю з вами згоден. Я сказав, що відчайдушно!
tenpn

3
Чи не може це також порушити коротке замикання та зробить передбачення гілок більш ненадійним?
Egon

1
Якщо foo дорівнює 2, а бар - 1, код взагалі не поводиться однаково. Це, а не рання оцінка, є найбільшим недоліком, я думаю.

1
Гостро, булеві значення в C ++ мають гарантію 0 або 1, доки ви це робите лише з булями, ви в безпеці. Більше: altdevblogaday.org/2011/04/18/understanding-your-bool-type
tenpn

1

Слідкуйте за використанням стеку

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

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