Деоптимізація програми для конвеєра в процесорах сімейства Intel Sandybridge


322

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

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

Для деоптимізації програми використовуйте свої знання про те, як працює трубопровід Intel i7. Уявіть, як переупорядкувати контури інструкцій для введення небезпек для WAR, RAW та інших небезпек. Подумайте про способи мінімізувати ефективність кешу. Будьте діаболічно некомпетентними.

Завдання дало вибір програм Whetstone або Monte-Carlo. Зауваження щодо ефективності кеш-пам'яті в основному стосуються лише Whetstone, але я вибрав програму імітації Монте-Карло:

// Un-modified baseline for pessimization, as given in the assignment
#include <algorithm>    // Needed for the "max" function
#include <cmath>
#include <iostream>

// A simple implementation of the Box-Muller algorithm, used to generate
// gaussian random numbers - necessary for the Monte Carlo method below
// Note that C++11 actually provides std::normal_distribution<> in 
// the <random> library, which can be used instead of this function
double gaussian_box_muller() {
  double x = 0.0;
  double y = 0.0;
  double euclid_sq = 0.0;

  // Continue generating two uniform random variables
  // until the square of their "euclidean distance" 
  // is less than unity
  do {
    x = 2.0 * rand() / static_cast<double>(RAND_MAX)-1;
    y = 2.0 * rand() / static_cast<double>(RAND_MAX)-1;
    euclid_sq = x*x + y*y;
  } while (euclid_sq >= 1.0);

  return x*sqrt(-2*log(euclid_sq)/euclid_sq);
}

// Pricing a European vanilla call option with a Monte Carlo method
double monte_carlo_call_price(const int& num_sims, const double& S, const double& K, const double& r, const double& v, const double& T) {
  double S_adjust = S * exp(T*(r-0.5*v*v));
  double S_cur = 0.0;
  double payoff_sum = 0.0;

  for (int i=0; i<num_sims; i++) {
    double gauss_bm = gaussian_box_muller();
    S_cur = S_adjust * exp(sqrt(v*v*T)*gauss_bm);
    payoff_sum += std::max(S_cur - K, 0.0);
  }

  return (payoff_sum / static_cast<double>(num_sims)) * exp(-r*T);
}

// Pricing a European vanilla put option with a Monte Carlo method
double monte_carlo_put_price(const int& num_sims, const double& S, const double& K, const double& r, const double& v, const double& T) {
  double S_adjust = S * exp(T*(r-0.5*v*v));
  double S_cur = 0.0;
  double payoff_sum = 0.0;

  for (int i=0; i<num_sims; i++) {
    double gauss_bm = gaussian_box_muller();
    S_cur = S_adjust * exp(sqrt(v*v*T)*gauss_bm);
    payoff_sum += std::max(K - S_cur, 0.0);
  }

  return (payoff_sum / static_cast<double>(num_sims)) * exp(-r*T);
}

int main(int argc, char **argv) {
  // First we create the parameter list                                                                               
  int num_sims = 10000000;   // Number of simulated asset paths                                                       
  double S = 100.0;  // Option price                                                                                  
  double K = 100.0;  // Strike price                                                                                  
  double r = 0.05;   // Risk-free rate (5%)                                                                           
  double v = 0.2;    // Volatility of the underlying (20%)                                                            
  double T = 1.0;    // One year until expiry                                                                         

  // Then we calculate the call/put values via Monte Carlo                                                                          
  double call = monte_carlo_call_price(num_sims, S, K, r, v, T);
  double put = monte_carlo_put_price(num_sims, S, K, r, v, T);

  // Finally we output the parameters and prices                                                                      
  std::cout << "Number of Paths: " << num_sims << std::endl;
  std::cout << "Underlying:      " << S << std::endl;
  std::cout << "Strike:          " << K << std::endl;
  std::cout << "Risk-Free Rate:  " << r << std::endl;
  std::cout << "Volatility:      " << v << std::endl;
  std::cout << "Maturity:        " << T << std::endl;

  std::cout << "Call Price:      " << call << std::endl;
  std::cout << "Put Price:       " << put << std::endl;

  return 0;
}

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


Оновлення: професор, який дав це завдання, розмістив деякі деталі

Основні моменти:

  • Це другий семестрський клас архітектури в коледжі громади (з використанням підручника Хеннесі та Паттерсона).
  • лабораторні комп'ютери мають процесори Haswell
  • Студенти ознайомилися з CPUIDінструкцією та способом визначення розміру кешу, а також властивостей та CLFLUSHінструкцій.
  • будь-які параметри компілятора дозволені, так само як і вбудований ASM.
  • Написання власного алгоритму квадратного кореня було оголошено як поза межами блідості

Зауваження Cowmoogun щодо метапотоку вказують на те, що не ясно, що оптимізація компілятора може бути частиною цього, і припускається-O0 , що збільшення часу виконання на 17% було розумним.

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


Майте на увазі, що це питання архітектури комп'ютера, а не питання про те, як зробити C ++ загалом.


97
Я чую, що i7 справляється дуже поганоwhile(true){}
Cliff AB

3
Номер 2 на HN atm: news.ycombinator.com/item?id=11749756
mlvljr

5
З openmp, якщо ви робите це погано, ви повинні мати можливість зробити N потоків зайняти довше, ніж 1.
Flexo

9
Зараз це питання обговорюється в мета
"Привид

3
@bluefeet: Я додав, що через те, що він вже був затриманий одним голосуванням за годину, коли він був повторно відкритий. Потрібно лише 5 людей, щоб прийти разом і VTC, не розуміючи, читаючи коментарі, щоб побачити, що це обговорюється на мета. Зараз ще одне закрите голосування. Я думаю, що принаймні одне речення допоможе уникнути циклів закриття / повторного відкриття.
Пітер Кордес

Відповіді:


405

Важливе попереднє читання: Microarch pdf Agner Fog , а також, можливо, також те, що повинен знати кожен програміст про пам'ять . Дивіться також інші посилання втегі-вікі, особливо посібники з оптимізації Intel, і аналіз Девід Кантера з мікроархітектури Haswell з діаграмами .

Дуже круте завдання; набагато краще, ніж ті, що я бачив, де студентів просили оптимізувати якийсь кодgcc -O0 , вивчаючи купу хитрощів, які не мають значення в реальному коді. У цьому випадку вас просять дізнатись про конвеєр процесора і використовувати його для керування вашими зусиллями з оптимізації, а не просто сліпими здогадами. Найцікавішою частиною цього є виправдання кожної песимізації "диявольською некомпетентністю", а не навмисною злобою.


Проблеми з формулюванням та кодом призначення :

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

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

Процесори сімейства Intel Sandybridge - це агресивні нестандартні конструкції, які витрачають багато транзисторів та потужність, щоб знайти паралелізм та уникнути небезпек (залежностей), які б заважали класичному трубопроводу RISC в порядку . Зазвичай єдиними традиційними небезпеками, які сповільнюють це, є "справжні" залежності RAW, які призводять до обмеження пропускної здатності затримкою.

Небезпеки для WAR та WAW для реєстрів - це не велика проблема, завдяки перейменуванню реєстру . (за виняткомpopcnt//lzcnt/tzcnt, які мають помилкову залежність від свого призначення від процесорів Intel , навіть якщо це лише для запису. тобто WAW обробляється як небезпека RAW + запис). Для впорядкування пам'яті сучасні процесори використовують черги на зберігання, щоб затримати фіксацію в кеш до виходу на пенсію, також уникаючи небезпек WAR та WAW .

Чому мульс займає лише 3 цикли на Haswell, відмінні від таблиць інструкцій Agner? детальніше про перейменування реєстру та приховування затримок FMA у циклі продуктових точок FP.


Бренд "i7" був представлений разом з Nehalem (спадкоємцем Core2) , а деякі посібники від Intel навіть кажуть "Core i7", коли, здається, означають Nehalem, але вони зберігали бренди "i7" для Sandybridge та пізніших мікроархітектур. SnB - це коли сім'я P6 перетворилася на новий вид, сімейство SnB . Багато в чому Nehalem має більше спільного з Pentium III, ніж із Sandybridge (наприклад, реєструвати збитки для читання та стійкі для читання ROB не трапляються на SnB, оскільки він змінився на використання файлу фізичного регістра. Також загальний кеш і інший внутрішній формат взагалі). Термін "архітектура i7" не корисний, тому що мало сенсу групувати сім'ю SnB з Nehalem, але не з Core2. (Хоча Nehalem ввів спільну архітектуру кешу L3 для з'єднання декількох ядер разом. А також інтегровані графічні процесори. Отже, на рівні чіпа, іменування має більше сенсу.)


Короткий зміст хороших ідей, якими може виправдатись дьявольська некомпетентність

Навіть діаболічно некомпетентні навряд чи додадуть очевидно марну роботу або нескінченний цикл, і заплутатися з класами C ++ / Boost виходить за рамки завдання.

  • Багатопотокові з одним лічильником спільного std::atomic<uint64_t> циклу, тому правильна загальна кількість ітерацій відбувається. Атомний uint64_t особливо поганий -m32 -march=i586. Для отримання бонусних балів слід домогтися її вирівнювання та перетину меж сторінки з нерівномірним розділенням (не 4: 4).
  • Неправдивий спільний доступ для деяких інших атомних змінних -> помилка замовлення пам’яті трубопроводу очищає, а також додаткові пропуски кешу.
  • Замість використання -на змінних FP, XOR високий байт з 0x80, щоб перевернути біт знаків, викликаючи стійло переадресації магазину .
  • Визначте кожну ітерацію самостійно, щось ще важче, ніж RDTSC. наприклад, CPUID/ RDTSCабо функція часу, яка здійснює системний виклик. Інструкції щодо серіалізації за своєю суттю не підходять для роботи.
  • Змініть множення на константи на ділення на їх зворотні ("для зручності читання"). div повільний і не повністю конвеєрний.
  • Векторизувати множення / sqrt за допомогою AVX (SIMD), але не використовувати його vzeroupperперед викликами до скалярної математичної бібліотеки exp()та log()функцій, викликаючи AVX <-> SSE перехідні стійли .
  • Зберігайте висновок RNG у пов'язаному списку або в масивах, які ви перебуваєте поза порядком. Те саме для результату кожної ітерації та підсумок в кінці.

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


Багатопотокові погано

Можливо, використовуйте OpenMP для багатопотокових циклів з дуже малою кількістю ітерацій, з набагато більшими накладними, ніж збільшення швидкості. Ваш код Монте-Карло має достатній паралелізм, щоб насправді отримати швидкість, хоча, особливо. якщо нам вдасться зробити кожну ітерацію повільною. (Кожна нитка обчислює часткове payoff_sum, додане в кінці). #omp parallelна цьому циклі, ймовірно, буде оптимізація, а не песимізація.

Багатопотокові, але змушуйте обидва потоки ділити один і той же лічильник циклу (з atomicкроком, щоб загальна кількість ітерацій була правильною). Це здається диявольськи логічним. Це означає використовувати staticзмінну як лічильник циклу. Це виправдовує використання atomicдля лічильників циклів і створює фактичну кеширувальну лінію ping-ponging (до тих пір, поки потоки не працюватимуть на одному фізичному ядрі з гіперточенням; це може бути не так повільно). У будь-якому випадку це набагато повільніше, ніж випадки, що не суперечать lock inc. А lock cmpxchg8bщоб атомний приріст, який претендує uint64_tна 32-бітну систему, доведеться повторити спробу в циклі замість того, щоб апаратний арбітраж атомного inc.

Також створіть помилковий обмін , де кілька потоків зберігають свої приватні дані (наприклад, стан RNG) у різних байтах однієї лінії кешу. (Навчальний посібник Intel про це, включаючи лічильники парфу для перегляду) . У цьому є специфічний для мікроархітектури аспект : процесори Intel спекулюють на тому, що неправильне впорядкування пам’яті не відбувається, і для того, щоб виявити це, принаймні на P4 , існує порядок оперативної пам’яті в порядку пам'яті . Штраф може бути не таким великим для Haswell. Як вказує це посилання, lockредакція редактора передбачає, що це станеться, уникаючи помилок. Звичайне навантаження припускає, що інші ядра не можуть визнати недійсним рядок кешу між тим, коли завантаження виконується, і коли воно скасовується в програмному порядку (якщо ви не використовуєтеpause ). Справжній спільний доступ без lockінструкцій редагування зазвичай є помилкою. Було б цікаво порівняти неатомний лічильник спільного циклу з атомним корпусом. Щоб дійсно песимізувати, збережіть лічильник загального атомного циклу та викликайте помилковий обмін у тій же чи іншій лінії кешу для якоїсь іншої змінної.


Випадкові ідеї, характерні для урха:

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


Ланцюги залежностей:

Я думаю, це було однією із запланованих частин завдання.

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

Щоб дійсно зробити це ефективним, збільште довжину ланцюга залежності, що переноситься циклом. Ніщо не вискакує як очевидне: Написані петлі мають дуже короткі ланцюги залежностей, що переносяться циклом: лише FP-додаток. (3 цикли). Багаторазові ітерації можуть мати обчислення під час польоту одразу, оскільки вони можуть розпочатися задовго до payoff_sum +=кінця попередньої ітерації. ( log()і expвізьміть багато інструкцій, але не набагато більше, ніж вікно поза замовлення Haswell для пошуку паралелізму: ROB size = 192 Uops з конденсованим доменом, і розмір планувальника = 60 Uops з конденсованим доменом. Як тільки виконання поточної ітерації прогресує досить далеко, щоб звільнити місце для вказівок щодо наступної ітерації щодо випуску, будь-які її частини, які мають готові входи (тобто незалежний / окремий ланцюг dep), можуть почати виконання, коли старші інструкції залишають одиниці виконання безкоштовно (наприклад, через те, що вони обмежені затримкою, а не пропускною здатністю.)

Стан RNG майже напевно буде більш тривалим циклом залежності, ніж цей addps.


Використовуйте більш повільні / більш операції з ПП (особливо підрозділ):

Ділимо на 2.0, а не множимо на 0,5 тощо. Мультиплікаційний потенціал є сильно конвеєрним в конструкціях Intel і має пропускну здатність на 0,5 с на Haswell і пізніше. FP divsd/ divpdє лише частково конвеєрним . (Хоча Skylake має вражаючу пропускну здатність на 4c для divpd xmm, із затримкою 13-14c, проти Нехалема зовсім (7-22c)).

Це do { ...; euclid_sq = x*x + y*y; } while (euclid_sq >= 1.0);чітко тестує відстань, так що явно це було б належним sqrt()чином. : P ( sqrtнавіть повільніше, ніж div).

Як пропонує @Paul Clayton, переписання виразів з асоціативними / розподільними еквівалентами може ввести більше роботи (доки ви не використовуєте, -ffast-mathщоб дозволити компілятору повторно оптимізувати). (exp(T*(r-0.5*v*v))міг стати exp(T*r - T*v*v/2.0). Зауважте, що математика на реальних числах асоціативна, математика з плаваючою комою не є , навіть не враховуючи переповнення / NaN (через що -ffast-mathза замовчуванням не ввімкнено). Дивіться коментар Павла щодо дуже волохатої вкладеної pow()пропозиції.

Якщо ви можете масштабувати обчислення до дуже малих чисел, то FP математика може взяти ~ 120 додаткових циклів, щоб потрапити в мікрокод, коли операція з двома нормальними числами дає денормальне . Точні номери та деталі див. У pdf-файлі Microarch pg Agner Fog. Це малоймовірно, оскільки у вас багато примножень, тому масштабний коефіцієнт буде розміщений у квадраті та переповнено до 0,0. Я не бачу жодного способу виправдати необхідне масштабування некомпетентністю (навіть диявольською), лише навмисною злобою.


Якщо ви можете використовувати внутрішні слова ( <immintrin.h>)

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

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

Змішування інструкцій SSE та AVX без належного використання vzeroupperспричиняє великі стійки в попередньому Skylake (і різний штраф у Skylake ). Навіть без цього векторизація погано може бути гіршою, ніж скалярна (більше циклів витрачає переміщення даних у / з векторів, ніж збережене, виконуючи операції add / sub / mul / div / sqrt одночасно для 4 ітерацій Монте-Карло, з векторами 256b) . Блоки виконання add / sub / mul є повністю конвеєрними та на повну ширину, але div та sqrt на векторах 256b не такі швидкі, як на векторах 128b (або скалярах), тому швидкість роботи не є драматичноюdouble.

exp()і log()не мають апаратної підтримки, щоб ця частина вимагала вилучення векторних елементів назад до скалярного і виклику функції бібліотеки окремо, а потім переміщення результатів назад у вектор. Як правило, libm компілюється лише для використання SSE2, тому використовуватиме застарілі SSE-кодування скалярних інструкцій з математики. Якщо ваш код використовує 256b векторів і дзвінки, expне роблячи vzeroupperспочатку, то ви зупиняєтесь. Після повернення інструкція AVX-128, як vmovsdналаштувати наступний векторний елемент як аргумент для exp, також зупиниться. А потім exp()знову зупиниться, коли він виконує інструкцію SSE. Саме це сталося в цьому питанні , спричинивши 10-кратне уповільнення. (Дякую @ZBoson).

Дивіться також експерименти Натана Курца з математичною lib Intel проти glibc для цього коду . Майбутній glibc поставиться з векторизованими реалізаціями exp()тощо.


Якщо націлено на попередній показник IvB або esp. Негалем, спробуйте отримати gcc, щоб викликати стійкі часткові регістри з операціями 16 біт або 8 біт з наступними операціями з 32 або 64 бітами. У більшості випадків gcc буде використовуватися movzxпісля 8 або 16-бітної операції, але ось випадок, коли gcc змінюється, ahа потім читаєтьсяax


З (вбудованим) посиланням:

За допомогою (вбудованого) ASM ви можете зламати кеш взагалі: 32B фрагмент коду, який не вміщується у трьох лініях кешу 6uop, змушує переключитися з кеша взагалі на декодери. Некомпетентність, що ALIGNвикористовує багато однобайтових nops замість пари довгих nops на цілі гілки всередині внутрішнього циклу, може зробити цю справу. Або покладіть накладку вирівнювання після мітки, а не перед. : P Це має значення лише в тому випадку, якщо передній край - це вузьке місце, якого не буде, якщо нам вдалося песимізувати решту коду.

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

LCP кіоски з 16-бітових інструкцій із занадто великими безпосередніми розмірами, щоб вміститись у 8 біт, навряд чи будуть корисними. Загальний кеш на SnB і пізніше означає, що ви сплачуєте штраф декодування лише один раз. У Nehalem (перший i7) він може працювати для циклу, який не вписується в буфер циклу 28 взагалі. gcc інколи буде генерувати такі інструкції, навіть -mtune=intelколи і коли він міг би використовувати 32-бітну інструкцію.


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


Причиняється багато пропусків кешу та інших сповільнень пам'яті

Використовуйте union { double d; char a[8]; }для деяких своїх змінних. Викликайте стійло переадресації магазину , виконавши вузький магазин (або Read-Modify-Write) лише до одного з байтів. (Ця стаття у вікі також охоплює багато інших мікроархітектурних матеріалів для черг на завантаження / зберігання). наприклад, переверніть знак doubleвикористання XOR 0x80 лише на високому байті , а не на -операторі. Діаболічно некомпетентний розробник, можливо, почув, що FP повільніше, ніж ціле число, і, таким чином, спробуйте зробити якомога більше, використовуючи цілі ops. (Дуже хороший компілятор, орієнтований на математику FP в регістрах SSE, можливо, може скласти це доxorps з константою в іншому регістрі xmm, але єдиний спосіб, який не є страшним для x87, це якщо компілятор зрозуміє, що це заперечує значення і замінює наступне додавання на віднімання.)


Використовуйте, volatileякщо ви компілюєте -O3та не використовуєте std::atomic, щоб змусити компілятор фактично зберігати / перезавантажувати всюди. Глобальні змінні (замість локальних) також змусять деякі магазини / перезавантаження, але слабке впорядкування моделі пам’яті C ++ не вимагає, щоб компілятор весь час розливався / перезавантажувався в пам'ять.

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

Використовуйте масиви в структурі для заміщення (і зберігання випадкових чисел для обгрунтування їх існування).

Виберіть макет пам’яті, щоб все перейшло в інший рядок у тому ж «наборі» в кеші L1 . Це лише восьмисторонній асоціативний характер, тобто кожен набір має 8 "способів". Лінії кеш-пам'яті - 64B.

Ще краще, розмістіть речі рівно на 4096B, оскільки навантаження мають помилкову залежність від магазинів на різних сторінках, але з однаковим зміщенням у межах сторінки . Агресивні процесори, що вийшли з ладу, використовують розбір пам’яті, щоб визначити, коли навантаження та сховища можуть бути впорядковані без зміни результатів , а реалізація Intel має помилкові позитиви, які не дозволяють завантажувати навантаження рано. Ймовірно, вони перевіряють лише біти нижче зміщення сторінки, тому перевірка може розпочатися до того, як TLB переклав високі біти з віртуальної сторінки на фізичну. Як і посібник Агнера, дивіться відповідь Стівена Канона , а також розділ наприкінці відповіді @Krazy Glew на те саме питання. (Енді Глі був одним з архітекторів Intel-оригінальної мікроархітектури P6.)

Використовуйте, __attribute__((packed))щоб дозволити неправильно вирівняти змінні, щоб вони охоплювали кеш-лінію або навіть межі сторінки. (Отже, для завантаження doubleпотрібні дані з двох кеш-рядків). Нерівні навантаження не мають штрафу в жодному процесорі Intel i7, крім випадків, коли перетинає рядки кешу та рядки сторінок. Розщеплення кеш-лінії все ще займає додаткові цикли . Skylake різко знижує штраф за розділене завантаження сторінки, зі 100 до 5 циклів. (Розділ 2.1.3) . Можливо, це стосується можливості паралельних двох прогулянок на сторінці.

Розбиття сторінки на аркуші atomic<uint64_t>має бути приблизно в гіршому випадку , особливо. якщо це 5 байт на одній сторінці і 3 байти на іншій сторінці, або що-небудь інше, ніж 4: 4. Навіть розщеплення в середині є більш ефективними для розщеплення кеш-ліній з 16В векторами на деяких урах, IIRC. Покладіть усе в alignas(4096) struct __attribute((packed))(звичайно, щоб заощадити місце), включаючи масив для зберігання результатів RNG. Досягніть нерівності, використовуючи uint8_tчи що- uint16_tнебудь перед лічильником.

Якщо ви можете змусити компілятор використовувати індексовані режими адресації, це переможе взагалі мікро-синтез . Можливо, використовуючи #defines для заміни простих скалярних змінних my_data[constant].

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


Поперечні масиви в безперервному порядку

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

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

Для максимальної песимізації переведіть цикл на свій масив з кроком 4096 байт (тобто 512 пар). напр

for (int i=0 ; i<512; i++)
    for (int j=i ; j<UPPER_BOUND ; j+=512)
        monte_carlo_step(rng_array[j]);

Отже шаблон доступу дорівнює 0, 4096, 8192, ...,
8, 4104, 8200, ...
16, 4112, 8208, ...

Це те, що ви отримаєте для доступу до 2D масиву, як double rng_array[MAX_ROWS][512]у неправильному порядку (перекидання рядків замість стовпців у рядку у внутрішньому циклі, як запропонував @JesperJuhl). Якщо діаболічна некомпетентність може виправдати двовимірний масив з такими розмірами, то некомпетентність у реальному масштабі саду легко виправдовує циклічність із неправильним шаблоном доступу. Це відбувається в реальному коді в реальному житті.

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

Це також призведе до безлічі пропусків TLB, якщо сторінки не об'єднаються у величезну сторінку ( Linux робить це умовно-mallocnewmmap(MAP_ANONYMOUS) методично для анонімних (не підтримуваних файлів) виділень, таких як / що використовують ).

Замість масиву для зберігання списку результатів можна використовувати зв'язаний список . Тоді кожна ітерація потребує навантаження, що переслідує покажчик (справжня небезпека залежності RAW для адреси навантаження наступного навантаження). З поганим розподільником, вам може вдатися розкидати вузли списку навколо пам’яті, перемігши кеш. Діаболічно некомпетентний розподільник може розмістити кожен вузол на початку своєї власної сторінки. (наприклад, розподіляти mmap(MAP_ANONYMOUS)безпосередньо, не розбиваючи сторінки та відстежуючи розміри об'єктів, щоб належним чином підтримувати free).


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

Дещо поза темою: змусити компілятор генерувати гірший код / ​​зробити більше роботи:

Використовуйте C ++ 11 std::atomic<int>і std::atomic<double>для найбільш песимального коду. Інструкції MFENCE та locked є досить повільними, навіть не маючи суперечок з іншого потоку.

-m32зробить повільніше код, тому що x87 код буде гірше, ніж код SSE2. 32-бітова конвенція на основі стека вимагає більше інструкцій і передає навіть FP-аргументи на стеку таким функціям exp(). atomic<uint64_t>::operator++для -m32вимагає lock cmpxchg8Bциклу (i586). (Тож використовуйте це для лічильників циклу! [Злий сміх]).

-march=i386буде також песимізувати (дякую @Jesper). FP порівняно з fcomповільнішими, ніж 686 fcomi. Pre-586 не забезпечує атомний 64-бітовий сховище (не кажучи вже про cmpxchg), тому всі 64-бітові atomicопераційні системи компілюються для викликів функції libgcc (що, ймовірно, компілюється для i686, а не фактично використовуючи замок). Спробуйте це за посиланням Godbolt Compiler Explorer в останньому абзаці.

Використовуйте long double/ sqrtl/ explдля додаткової точності та додаткової повільності в ABI, де sizeof ( long double) становить 10 або 16 (з накладкою для вирівнювання). (IIRC, 64 - розрядні Windows , використовує 8byte long doubleеквівалент double. ( У всякому разі, навантаження / магазин 10byte (80bit) FP операндами 4/7 микрооперации, VS. floatабо doubleтільки з 1 моп кожен для fld m64/m32/ fst). Примус x87 з long doubleураженнями автоматичної векторизації навіть для gcc -m64 -march=haswell -O3.

Якщо не використовуються atomic<uint64_t>лічильники циклів, використовуйте long doubleдля всього, включаючи лічильники циклів.

atomic<double>компілює, але операції читання-зміни-запису на зразок +=не підтримуються для нього (навіть на 64-бітній). atomic<long double>має викликати функцію бібліотеки лише для атомних навантажень / сховищ. Це, мабуть, дійсно неефективно, тому що x86 ISA, природно, не підтримує атомні 10-байтові навантаження / сховища , і єдиний спосіб, про який я можу подумати, не блокуючи ( cmpxchg16b), вимагає 64-бітового режиму.


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

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

Спробуйте uint16_tлічильники циклів, щоб змусити усікання до 16 біт, ймовірно, використовуючи 16-бітовий розмір операнду (потенційні стійла) та / або додаткові movzxінструкції (безпечно). Переповнення підпису є невизначеною поведінкою , тому, якщо ви не використовуєте -fwrapvабо принаймні -fno-strict-overflow, підписані циклічні лічильники не повинні повторно підписуватись за кожну ітерацію , навіть якщо вони використовуються як компенсації до 64-бітових покажчиків.


floatЗнову примусово перетворіть з цілого числа в та назад. Та / або double<=> floatперетворення. Інструкції мають більшу затримку, а скалярний int-> float ( cvtsi2ss) погано розроблений, щоб не нулювати решту регістра xmm. ( pxorз цієї причини gcc вставляє додатковий, щоб зламати залежності.)


Часто встановлюйте спорідненість CPU до іншого процесора (запропоновано @Egwor). дьявольські міркування: Ви не хочете, щоб одне ядро ​​довгий час перегрівалося від запуску своєї нитки, чи не так? Можливо, заміна на інше ядро ​​дозволить цій ядрі turbo підвищити тактову частоту. (Насправді: вони настільки термічно близькі один до одного, що це вкрай малоймовірно, за винятком багатосистемної системи). Тепер просто помиліться налаштуванням і робіть це занадто часто. Крім часу, витраченого на збереження / відновлення стану потоку ОС, у новому ядрі є холодні кеші L2 / L1, загальний кеш і передбачувачі гілок.

Введення частих непотрібних системних дзвінків може сповільнити вас незалежно від того, якими вони є. Хоча деякі важливі, але прості, такі як gettimeofdayможуть бути реалізовані в просторі користувача з, без переходу в режим ядра. (glibc в Linux робить це за допомогою ядра, оскільки ядро ​​експортує код у vdso).

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


10
@JesperJuhl: так, я куплю це виправдання. "діаболічно некомпетентний" така чудова фраза :)
Пітер Кордес

2
Зміна множини на постійне на ділення на обернену константу може дещо знизити продуктивність (принаймні, якщо хтось не намагається перехитрити -O3 -fastmath). Аналогічно використання асоціативності для збільшення роботи ( exp(T*(r-0.5*v*v))становлення exp(T*r - T*v*v/2.0); exp(sqrt(v*v*T)*gauss_bm)становлення exp(sqrt(v)*sqrt(v)*sqrt(T)*gauss_bm)). Асоціативність (і узагальнення) також може трансформуватися exp(T*r - T*v*v/2.0)в `pow ((pow (e_value, T), r) / pow (pow (pow ((pow (e_value, T), v), v), v)), - 2.0) [або щось таке Такі математичні трюки насправді не вважаються мікроархітектурними деоптимізаціями.
Пол А. Клейтон

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

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

4
Що? Ніяких мутексів? Маючи два мільйони ниток, що працюють одночасно з мютекс, що захищає кожне окреме обчислення (про всяк випадок!), Поставить на коліна найшвидший суперкомп'ютер планети. Це сказало: я люблю цю дьявольсько некомпетентну відповідь.
Девід Хаммен

35

Кілька речей, які ви можете зробити, щоб зробити роботу максимально поганою:

  • скласти код архітектури i386. Це запобіжить використанню SSE та новіших інструкцій та змусить використовувати x87 FPU.

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

  • переконайтеся, що доступ до пам’яті є найгіршим можливим способом для передбачувача передбачення (стовпчик-мажор проти основного рядка).

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

  • переконайтеся, що вся виділена вами пам’ять дуже дивно вирівняна, і всіляко уникайте розподілу величезних сторінок, оскільки це було б занадто ефективно TLB.

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

Примітка. Ця відповідь, в основному, просто узагальнює мої коментарі, які @Peter Cordes вже включив у свою дуже хорошу відповідь. Запропонуйте, що він отримає ваш внесок, якщо у вас є лише один запасний :)


9
Моє основне заперечення проти деяких із них - це формулювання питання: Щоб деоптимізувати програму, використовуйте свої знання про те, як працює трубопровід Intel i7 . Мені не здається, що у x87 є щось особливе, що стосується нерівномірності std::atomic, або додатковий рівень непрямості від динамічного розподілу. Вони також будуть повільними на Atom або K8. Проблема все ще надходить, але саме тому я протистояв деяким вашим пропозиціям.
Пітер Кордес

Це справедливі моменти. Незважаючи на це, ті речі все ще дещо працюють на мету того, хто ставить запитувача. Вдячний за підсумки :)
Jesper Juhl

Підрозділ SSE використовує порти 0, 1 і 5. Блок x87 використовує лише порти 0 і 1.
Michas

@Michas: Ти в цьому помилився. Haswell не виконує жодних інструкцій з математики SSE FP на порт 5. Переважно SSE FP перетасовує та булеві (xorps / andps / orps). x87 повільніше, але ваше пояснення чому трохи неправильне. (І цей пункт абсолютно неправильний.)
Пітер Кордес,

1
@Michas: movapd xmm, xmmзазвичай не потрібен порт виконання (він обробляється на етапі реєстрації-перейменування на IVB та пізніших версіях). Він також майже ніколи не потрібен в коді AVX, тому що все, крім FMA, не руйнує. Але справедливо, Haswell запускає його на port5, якщо його не усунути. Я не переглянув x87 register-copy ( fld st(i)), але ти маєш право на Haswell / Broadwell: він працює на p01. Skylake працює на p05, SnB - на p0, IvB - на p5. Таким чином, IVB / SKL роблять деякі матеріали x87 (включаючи порівняння) на p5, але SNB / HSW / BDW взагалі не використовують p5 для x87.
Пітер Кордес

11

Ви можете використовувати long doubleдля обчислення. На x86 це має бути 80-бітний формат. Підтримка цього має лише спадщина, x87 FPU.

Мало недоліків ФПУ x87:

  1. Відсутність SIMD, може знадобитися більше інструкцій.
  2. На основі стека, проблематично для супер скалярних та конвеєрних архітектур.
  3. Окремий і досить невеликий набір регістрів, можливо, потребує більшої конверсії з інших регістрів і більше операцій з пам'яттю.
  4. У Core i7 є 3 порти для SSE і лише 2 для x87, процесор може виконувати менше паралельних інструкцій.

3
Для скалярної математики самі інструкції з математики x87 є лише дещо повільнішими. Зберігання / завантаження 10-байтних операндів значно повільніше, але дизайн на основі стека x87, як правило, вимагає додаткових інструкцій (наприклад fxch). З -ffast-math, хороший компілятор може векторизації петлі методом Монте-Карло, хоча і x87 б запобігти.
Пітер Кордес

Я трохи продовжив свою відповідь.
Міхас

1
re: 4: Про який i7 uarch ви говорите і які вказівки? Haswell може працювати mulssна p01, але fmulтільки на p0. addssпрацює лише p1, як fadd. Є лише два порти виконання, які обробляють FP math ops. (Єдиним винятком з цього є те, що Skylake скинув виділений блок додавання і працює addssв підрозділах FMA на p01, але faddна p5. Отже, змішуючи деякі faddінструкції разом з fma...ps, ви можете теоретично зробити трохи більше загальної FLOP / с.)
Пітер Кордес

2
Також зауважте, що Windows X86-64 ABI має 64-бітну long double, тобто все ще просто double. Однак SysV ABI використовує 80bit long double. Крім того, re: 2: перейменування регістра викриває паралелізм у регістрах стеків. Архітектура на основі стека вимагає додаткових інструкцій, наприклад fxchg, esp. при переплетенні паралельних обчислень. Тож скоріше схоже на вираження паралелізму без зворотних подорожей пам’яті, ніж важко експлуатувати те, що там є. Не потрібно більше конверсій з інших рег. Не впевнений, що ти маєш на увазі під цим.
Пітер Кордес

6

Пізня відповідь, але я не вважаю, що ми зловживали пов’язаними списками та TLB.

Використовуйте mmap, щоб розподілити свої вузли, таким чином, що ви в основному використовуєте MSB адреси. Це має призвести до довгих ланцюгів пошуку TLB, сторінка - 12 біт, залишаючи 52 біт для перекладу, або близько 5 рівнів, які вона повинна проходити кожен раз. З невеликою долею вони повинні кожен раз переходити в пам'ять для 5-ти рівних пошуку плюс 1 доступ до пам’яті, щоб дістатися до вашого вузла, найвищий вміст буде десь у кеші, тому ми можемо сподіватися на 5 * доступ до пам'яті. Розмістіть вузол таким чином, щоб пройти найгіршу межу, щоб прочитання наступного вказівника призвело до ще 3-х пошукових переходів. Це також може повністю зруйнувати кеш через велику кількість пошукових запитів. Також розмір віртуальних таблиць може призвести до того, що більшу частину даних користувача буде додано на диск на додатковий час.

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


Сторінки сторінок x86-64 на 4 рівні глибиною для 48-бітних віртуальних адрес (PTE має 52 біти фізичної адреси). Майбутні процесори підтримуватимуть 5-рівневу сторінку сторінок для ще 9 біт віртуального адресного простору (57). Чому у 64-бітової віртуальної адреси короткий 4 біт (48 біт) порівняно з фізичною адресою (52 біт)? . ОС не вмикає його за замовчуванням, оскільки це буде повільніше і не приносить користі, якщо вам не потрібно стільки адресного простору virt.
Пітер Кордес

Але так, весела ідея. Можливо, ви можете використовувати mmapу файлі або області спільної пам’яті для отримання декількох віртуальних адрес для однієї фізичної сторінки (з тим самим вмістом), що дозволяє більше пропускань TLB за однакову кількість фізичної оперативної пам’яті. Якщо ваш зв'язаний список nextбув лише відносним зміщенням , ви могли б здійснити серію відображень однієї і тієї ж сторінки з а, +4096 * 1024поки ви нарешті не потрапите на іншу фізичну сторінку. Або звичайно, що охоплює кілька сторінок, щоб уникнути звернень до кешу L1d. Існує кешування PDE вищого рівня в апаратному режимі проходу сторінок, так що так, розкладіть його у просторі virt addr!
Пітер Кордес

Додавання зміщення до старої адреси також погіршує затримку в завантаженні, переможачи [особливий випадок для [reg+small_offset]режиму адресації] ( Чи існує штраф, коли база + зміщення знаходиться на іншій сторінці, ніж основна? ); ви або отримаєте джерело пам'яті add64-бітного зміщення, або ви отримаєте навантаження та індексований режим адресації, як [reg+reg]. Також дивіться Що станеться після пропуску L2 TLB? - завантаження сторінок через кеш L1d у сімействі SnB.
Пітер Кордес
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.