Как правилно да сравните [шаблонна] C++ програма

‹ фон›

Намирам се в момент, в който наистина трябва да оптимизирам C++ кода. Пиша библиотека за молекулярни симулации и трябва да добавя нова функция. Вече се опитах да добавя тази функция в миналото, но след това използвах виртуални функции, извикани във вложени цикли. Имах лоши предчувствия за това и първото изпълнение доказа, че това е лоша идея. Това обаче беше добре за тестване на концепцията.

< /заден план>

Сега имам нужда тази функция да бъде възможно най-бърза (добре, без код за асемблиране или изчисление на GPU, това все пак трябва да е C++ и по-четливо от по-малко). Сега знам малко повече за шаблоните и класовите политики (от отличната книга на Александреску) и мисля, че генерирането на код по време на компилация може да е решението.

Трябва обаче да тествам дизайна, преди да направя огромната работа по внедряването му в библиотеката. Въпросът е за най-добрия начин да се тества ефективността на тази нова функция.

Очевидно трябва да включа оптимизациите, защото без това g++ (и вероятно други компилатори също) ще запази някои ненужни операции в обектния код. Също така трябва да използвам сериозно новата функция в бенчмарка, защото делта от 1e-3 секунди може да направи разликата между добър и лош дизайн (тази функция ще бъде извикана милиони пъти в истинската програма).

Проблемът е, че g++ понякога е твърде умен, докато оптимизира и може да премахне цял цикъл, ако прецени, че резултатът от изчисление никога не се използва. Вече видях това веднъж, когато гледах изходния асемблен код.

Ако добавя малко отпечатване към stdout, компилаторът ще бъде принуден да направи изчислението в цикъла, но вероятно най-вече ще сравнявам изпълнението на iostream.

И така, как мога да направя правилен бенчмарк на малка функция, извлечена от библиотека? Свързан въпрос: правилен подход ли е да се правят този вид in vitro тестове върху малка единица или имам нужда от целия контекст?

Благодаря за съветите!


Изглежда има няколко стратегии, от специфични за компилатора опции, позволяващи фина настройка, до по-общи решения, които трябва да работят с всеки компилатор като volatile или extern.

Мисля, че ще опитам всички тези. Благодаря много за всичките ви отговори!


person ascobol    schedule 12.01.2009    source източник
comment
Никога не съм виждал компилатора да е твърде умен. Не е разрешено премахването на неща, които имат страничен ефект или се използват от друга част от кода. Така че изглежда, че имате грешка в кода си.   -  person Martin York    schedule 12.01.2009
comment
Ако направете променлива глобална и я маркирайте като външна. Компилаторът не може да разбере, че се използва в други компилационни единици и по този начин няма да премахне инициализацията си. Фактът, че го прави, предполага, че правите нещо друго нередно.   -  person Martin York    schedule 12.01.2009


Отговори (10)


Ако искате да принудите който и да е компилатор да не отхвърля резултат, накарайте го да запише резултата в непостоянен обект. Тази операция не може да бъде оптимизирана по дефиниция.

template<typename T> void sink(T const& t) {
   volatile T sinkhole = t;
}

Без излишни разходи за iostream, само копие, което трябва да остане в генерирания код. Сега, ако събирате резултати от много операции, най-добре е да не ги изхвърляте една по една. Тези копия все още могат да добавят допълнителни разходи. Вместо това по някакъв начин съберете всички резултати в единичен енергонезависим обект (така че са необходими всички индивидуални резултати) и след това присвоете този резултатен обект на летлив обект. напр. ако всички ваши отделни операции произвеждат низове, можете да принудите оценката, като добавите всички стойности на char заедно по модул 1‹‹32. Това добавя почти никакви режийни разходи; низовете вероятно ще бъдат в кеша. Резултатът от добавянето впоследствие ще бъде присвоен на volatile, така че всеки символ във всеки sting трябва всъщност да бъде изчислен, без да се допускат преки пътища.

person MSalters    schedule 13.01.2009

Освен ако нямате наистина агресивен компилатор (може да се случи), бих предложил да изчислите контролна сума (просто добавете всички резултати заедно) и да изведете контролната сума.

Освен това, може да искате да погледнете генерирания асемблерен код, преди да изпълните някакви сравнителни тестове, за да можете визуално да проверите дали всички цикли действително се изпълняват.

person Jasper Bekkers    schedule 12.01.2009

На компилаторите е разрешено само да елиминират кодови разклонения, които не могат да се случат. Докато не може да изключи, че даден клон трябва да бъде изпълнен, той няма да го елиминира. Докато някъде има някаква зависимост от данни, кодът ще бъде там и ще се изпълнява. Компилаторите не са твърде умни да преценят кои аспекти на дадена програма няма да бъдат изпълнени и не се опитвайте да го направите, защото това е NP проблем и трудно може да се изчисли. Те имат някои прости проверки, като например за if (0), но това е всичко.

Моето скромно мнение е, че вероятно сте били засегнати от някакъв друг проблем по-рано, като например начина, по който C/C++ оценява булеви изрази.

Но както и да е, тъй като това е тест за скорост, можете сами да проверите дали нещата се извикват - изпълнете го веднъж без, след това друг път с тест на върнатите стойности. Или статична променлива се увеличава. В края на теста отпечатайте генерираното число. Резултатите ще бъдат равни.

За да отговоря на въпроса ви относно ин-витро тестването: Да, направете го. Ако приложението ви е толкова критично във времето, направете го. От друга страна, вашето описание намеква за различен проблем: ако вашите делти са във времева рамка от 1e-3 секунди, тогава това звучи като проблем с изчислителна сложност, тъй като въпросният метод трябва да се извиква много, много често (за няколко пробега, 1e-3 секунди е пренебрежимо).

Проблемният домейн, който моделирате, звучи МНОГО сложен и наборите от данни вероятно са огромни. Такива неща винаги са интересно усилие. Уверете се обаче, че първо имате абсолютно правилните структури от данни и алгоритми и след това микрооптимизирайте всичко, което искате. И така, бих казал първо да разгледате целия контекст. ;-)

От любопитство, какъв е проблемът, който изчислявате?

person mstrobl    schedule 12.01.2009
comment
Делтите бяха за едно извикване на функция, тъй като тази функция ще бъде извикана няколко милиона пъти. Работя в областта на макромолекулните взаимодействия (много атоми, транслации, ротации...). Новият код трябва да променя координатите на всеки атом преди всяко преместване/въртене. - person ascobol; 12.01.2009

Имате голям контрол върху оптимизациите за вашата компилация. -O1, -O2 и така нататък са просто псевдоними за куп превключватели.

От страниците на man

       -O2 turns on all optimization flags specified by -O.  It also turns
       on the following optimization flags: -fthread-jumps -falign-func‐
       tions  -falign-jumps -falign-loops  -falign-labels -fcaller-saves
       -fcrossjumping -fcse-follow-jumps  -fcse-skip-blocks
       -fdelete-null-pointer-checks -fexpensive-optimizations -fgcse
       -fgcse-lm -foptimize-sibling-calls -fpeephole2 -fregmove -fre‐
       order-blocks  -freorder-functions -frerun-cse-after-loop
       -fsched-interblock  -fsched-spec -fschedule-insns  -fsched‐
       ule-insns2 -fstrict-aliasing -fstrict-overflow -ftree-pre
       -ftree-vrp

Можете да промените и използвате тази команда, за да ви помогне да стесните опциите, които да проучите.

       ...
       Alternatively you can discover which binary optimizations are
       enabled by -O3 by using:

               gcc -c -Q -O3 --help=optimizers > /tmp/O3-opts
               gcc -c -Q -O2 --help=optimizers > /tmp/O2-opts
               diff /tmp/O2-opts /tmp/O3-opts Φ grep enabled

След като намерите виновната оптимизация, не трябва да имате нужда от cout.

person J.J.    schedule 12.01.2009
comment
Проблемът е, че точно тази оптимизация може да е желана на друго място или дори решаваща за цялостната производителност на шаблона. - person Konrad Rudolph; 13.01.2009

Ако това е възможно за вас, можете да опитате да разделите кода си на:

  • библиотеката, която искате да тествате, е компилирана с включени всички оптимизации
  • тестова програма, динамично свързваща библиотеката, с изключени оптимизации

В противен случай можете да посочите различно ниво на оптимизация (изглежда, че използвате gcc...) за тестовата функция с атрибута optimize (вижте http://gcc.gnu.org/onlinedocs/gcc/Function-Attributes.html#Function-Attributes).

person Paolo Tedesco    schedule 12.01.2009

Можете да създадете фиктивна функция в отделен cpp файл, който не прави нищо, но приема като аргумент какъвто и да е типът на вашия резултат от изчислението. След това можете да извикате тази функция с резултатите от вашето изчисление, принуждавайки gcc да генерира междинния код и единственото наказание е цената за извикване на функция (която не трябва да изкривява вашите резултати, освен ако не я наричате много! ).

person Tony    schedule 12.01.2009
comment
+1 – Изводът: намерете най-малко въздействащата част от кода, която можете да добавите към вашия цикъл за сравнителен анализ, за ​​да подмамите компилатора да повярва, че наистина прави нещо. Например, добавете индекса на итерация и т.н. към фиктивна променлива за контролна сума, която получава изход/пропуска. - person Ates Goral; 12.01.2009
comment
Не е ли това изместването на проблема? не може ли компилаторът все още да мисли, че нещо е без значение за изчисляването на върнатата стойност и да го оптимизира? - person Paolo Tedesco; 12.01.2009

редактиране: най-лесното нещо, което можете да направите, е просто да използвате данните по някакъв фалшив начин, след като функцията е изпълнила и извън вашите показатели. Като,

StartBenchmarking(); // ie, read a performance counter
for (int i=0; i<500; ++i)
 {
   coords[i][0] = 3.23;
   coords[i][1] = 1.345;
   coords[i][2] = 123.998;
 }
StopBenchmarking(); // what comes after this won't go into the timer

// this is just to force the compiler to use coords
double foo;
for (int j = 0 ; j < 500 ; ++j )
{
  foo += coords[j][0] + coords[j][1] + coords[j][2]; 
}
cout << foo;

Това, което понякога работи за мен в тези случаи, е да скрия теста in vitro във функция и да предам наборите от сравнителни данни чрез нестабилни указатели. Това казва на компилатора, че не трябва да свива последващи записи в тези указатели (защото те може да са напр. I/O картографирани в паметта). Така,

void test1( volatile double *coords )
{
  //perform a simple initialization of all coordinates:
  for (int i=0; i<1500; i+=3)
  {
    coords[i+0] = 3.23;
    coords[i+1] = 1.345;
    coords[i+2] = 123.998;
  }
}

По някаква причина, която все още не съм разбрал, не винаги работи в MSVC, но често го прави - погледнете изхода на асемблирането, за да сте сигурни. Също така не забравяйте, че volatile ще провали някои оптимизации на компилатора (той забранява на компилатора да пази съдържанието на указателя в регистъра и принуждава записите да се извършват в програмния ред), така че това е надеждно само ако го използвате за окончателно изписване на данни.

Като цяло in vitro тестване като това е много полезно, стига да помните, че това не е цялата история. Обикновено тествам новите си математически съчетания изолирано по този начин, за да мога бързо да повторя само характеристиките на кеша и конвейера на моя алгоритъм върху последователни данни.

Разликата между профилиране в епруветка като това и пускането му в "реалния свят" означава, че ще получите изключително различни входни набори от данни (понякога в най-добрия случай, понякога в най-лошия случай, понякога патологични), кешът ще бъде в някакво неизвестно състояние при влизане функцията и може да имате други нишки, които блъскат в автобуса; така че трябва да изпълните някои сравнителни тестове на тази функция in vivo също, когато приключите.

person Crashworks    schedule 12.01.2009

Не знам дали GCC има подобна функция, но с VC++ можете да използвате:

#pragma optimize

за селективно включване/изключване на оптимизациите. Ако GCC има подобни възможности, можете да изградите с пълна оптимизация и просто да я изключите, където е необходимо, за да сте сигурни, че вашият код ще бъде извикан.

person Ferruccio    schedule 12.01.2009

Само малък пример за нежелана оптимизация:

#include <vector>
#include <iostream>

using namespace std;

int main()
{
double coords[500][3];

//perform a simple initialization of all coordinates:
for (int i=0; i<500; ++i)
 {
   coords[i][0] = 3.23;
   coords[i][1] = 1.345;
   coords[i][2] = 123.998;
 }


cout << "hello world !"<< endl;
return 0;
}

Ако коментирате кода от "double coords[500][3]" до края на for цикъла, той ще генерира точно същия асемблерен код (току-що опитах с g++ 4.3.2). Знам, че този пример е твърде прост и не успях да покажа това поведение със std::vector на проста структура "Координати".

Въпреки това мисля, че този пример все още показва, че някои оптимизации могат да въведат грешки в бенчмарка и исках да избегна някои изненади от този вид при въвеждането на нов код в библиотека. Лесно е да си представим, че новият контекст може да попречи на някои оптимизации и да доведе до много неефективна библиотека.

Същото трябва да важи и за виртуалните функции (но не го доказвам тук). Използва се в контекст, в който статична връзка би свършила работата, аз съм доста уверен, че достойните компилатори трябва да премахнат допълнителното индиректно повикване за виртуалната функция. Мога да опитам това извикване в цикъл и да заключа, че извикването на виртуална функция не е толкова голяма работа. След това ще го извикам сто хиляди пъти в контекст, в който компилаторът не може да познае какъв ще бъде точният тип на указателя и има 20% увеличение на времето за изпълнение...

person ascobol    schedule 12.01.2009
comment
Изглежда ми добра оптимизация! coords[] никога не се използва и няма странични ефекти, когато е назначен. Не се оплаквайте само защото компилаторът е по-умен от вас (НИКОГА няма да премахне неща, които ви трябват, които използвате или имат страничен ефект). - person Martin York; 12.01.2009
comment
Разбира се, това е добра работа! Исках да покажа колко трудно е да се изпробва функция извън контекста: ако сведа проблема до най-малкото нещо, което искам да тествам, компилаторът може да не направи нищо или да се надявам на някои оптимизации, които няма да направи можете да правите в реалния живот. - person ascobol; 12.01.2009

при стартиране, чете от файл. във вашия код кажете if(input == "x") cout‹‹ result_of_benchmark;

Компилаторът няма да може да елиминира изчислението и ако се уверите, че входът не е "x", няма да сравните iostream.

person Community    schedule 12.01.2009