Оказывание программы для конвейера в процессорах Intel Sandybridge

Я пробовал свой мозг на неделю, пытаясь выполнить это задание, и я надеюсь, что кто-то здесь может привести меня к правильному пути. Позвольте мне начать с инструкций инструктора:

Ваше задание является противоположностью нашего первого лабораторного задания, которое должно было оптимизировать программу простых чисел. Ваша цель в этом назначении - пессимизировать программу, то есть заставить ее работать медленнее. Обе из них - программы с интенсивным использованием процессора. Они занимают несколько секунд, чтобы работать на наших лабораторных компьютерах. Вы не можете изменять алгоритм.

Чтобы обезвредить программу, используйте свои знания о том, как работает конвейер 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;
}

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


Обновление: профессор, который дал это задание, разместил некоторые детали

Основные моменты:

  • Это второй класс архитектуры семестра в колледже (используя учебник Hennessy и Patterson).
  • Лабораторные компьютеры имеют процессоры Haswell
  • Студенты были ознакомлены с инструкцией CPUID и как определить размер кеша, а также встроенные функции и инструкцию CLFLUSH.
  • допустимы любые параметры компилятора, а также встроенный asm.
  • Написание собственного алгоритма с квадратным корнем объявлено как находящееся за пределами бледного

Комментарии Cowmoogun в мета-потоке указывают, что не было ясно, что оптимизация компилятора может быть частью этого и предполагаемого -O0, и что увеличение на 17% во время выполнения было разумным.

Итак, похоже, что цель задания заключалась в том, чтобы заставить учащихся переупорядочить существующую работу, чтобы уменьшить уровень на уровне инструкций parallelism или что-то в этом роде, но это не плохо, что люди углубились и узнали больше.


Имейте в виду, что это вопрос компьютерной архитектуры, а не вопрос о том, как сделать С++ медленным в целом.

Ответ 1

Важная справочная информация: микроарх Agner Fog pdf и, вероятно, также Ульрих Дреппер, что каждый программист должен знать о памяти. См. Также другие ссылки в вики-теге , особенно руководства по оптимизации Intel, и анализ Дэвидом Кантером микроархитектуры Haswell с диаграммами.

Очень классное задание; Гораздо лучше, чем те, которые я видел, где студентов просили оптимизировать некоторый код для gcc -O0, изучая кучу трюков, которые не имеют значения в реальном коде. В этом случае вас просят узнать о конвейере ЦП и использовать его, чтобы направлять свои усилия по де-оптимизации, а не просто слепое предположение. Самая забавная часть этого оправдывает каждую пессимизацию "дьявольской некомпетентностью", а не преднамеренной злобой.


Проблемы с назначением формулировки и кода:

Параметры, специфичные для uarch, для этого кода ограничены. Он не использует никаких массивов, и большая часть затрат - это вызовы функций библиотеки exp/log. Не существует очевидного способа получить более или менее параллелизм на уровне команд, и цепочка зависимостей, переносимых циклами, очень коротка.

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

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

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

Почему Мулсс занимает всего 3 цикла в Haswell, в отличие от таблиц инструкций Агнера? больше о переименовании регистров и сокрытии задержки FMA в цикле FP.


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


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

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

  • Многопоточность с одним общим std::atomic<uint64_t> цикла 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.
  • Сохраните выходные данные ГСЧ в связанном списке или в массивах, которые вы просматриваете не по порядку. То же самое для результата каждой итерации, и сумма в конце.

Также рассматривается в этом ответе, но исключается из резюме: предложения, которые были бы такими же медленными для непотрубного процессора, или которые не кажутся оправданными даже при дьявольской некомпетентности. например, много идей gimp-the-compiler, которые приводят к явно другому/худшему асму.


Многопоточность плохо

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

Многопоточность, но вынуждает оба потока использовать один и тот же счетчик цикла (с atomic приращениями, поэтому общее число итераций будет правильным). Это кажется дьявольски логичным. Это означает использование static переменной в качестве счетчика цикла. Это оправдывает использование atomic счетчика циклов и создает фактический пинг-понг на линии кэша (если потоки не работают на одном физическом ядре с гиперпоточностью; это может быть не так медленно). Во всяком случае, это намного медленнее, чем неконтролируемый случай для lock inc И lock cmpxchg8b для атомарного увеличения uint64_t в 32-битной системе должна будет повторяться в цикле вместо аппаратного арбитража атомного inc.

Также создайте ложное совместное использование, когда несколько потоков хранят свои личные данные (например, состояние RNG) в разных байтах одной и той же строки кэша. (Учебное пособие Intel об этом, включая счетчики перфорации). В этом есть специфический аспект микроархитектуры: процессоры Intel спекулируют на том, что не происходит неправильного упорядочения памяти, и существует событие машинного сброса порядка порядка памяти, чтобы обнаружить это, по крайней мере, на P4. Наказание может быть не таким большим на Haswell. Как указывает эта ссылка, lock инструкция предполагает, что это произойдет, избегая неправильных предположений. Нормальная загрузка предполагает, что другие ядра не будут делать недействительной строку кэша между тем, когда загрузка выполняется и когда она удаляется в программном порядке (если вы не используете pause). Правильный обмен без lock инструкций обычно является ошибкой. Было бы интересно сравнить неатомарный счетчик общего цикла с атомарным случаем. Чтобы по-настоящему пессимизировать, сохраняйте счетчик общего атомарного цикла и вызывайте ложное совместное использование в той же или другой строке кэша для некоторой другой переменной.


Случайные уарх-специфические идеи:

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


Цепочки зависимостей:

Я думаю, что это была одна из предполагаемых частей задания.

Убейте способность ЦП использовать параллелизм на уровне команд, выбрав порядок операций с одной длинной цепочкой зависимостей вместо нескольких коротких цепочек зависимостей. Компиляторам не разрешается изменять порядок операций для вычислений FP, если вы не используете -ffast-math, потому что это может изменить результаты (как обсуждается ниже).

Чтобы действительно сделать это эффективным, увеличьте длину цепочки зависимостей, переносимых циклами. Тем не менее, ничто не проявляется так очевидно: циклы, как написано, имеют очень короткие переносимые циклы цепочки зависимостей: просто добавление FP. (3 цикла). Несколько итераций могут иметь свои расчеты в полете одновременно, потому что они могут начинаться задолго до payoff_sum += в конце предыдущей итерации. (log() и exp требуют много инструкций, но не намного больше, чем окно с неправильным порядком Haswell для поиска параллелизма: размер ROB = 192 мопов в слитых доменах и размер планировщика = 60 мопов в неиспользуемых доменах. Как только выполняется текущей итерации продвигается достаточно далеко, чтобы освободить место для инструкций следующей итерации, чтобы выпустить, любые ее части, у которых есть готовые входные данные (т.е. независимая/отдельная цепочка dep), могут начать выполняться, когда более старые инструкции оставляют блоки выполнения свободными (например, потому они имеют узкое место по задержке, а не по пропускной способности.)

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


Используйте более медленные/больше операций FP (особенно больше деления):

Разделите на 2,0 вместо умножения на 0,5 и так далее. Умножение FP сильно конвейеризовано в проектах Intel, и имеет пропускную способность на 0.5c в Haswell и позже. FP divsd/divpd только частично конвейеризован. (Хотя Skylake имеет впечатляющую пропускную способность на 4c для divpd xmm, с задержкой 13-14c, по сравнению с конвейером на Nehalem (7-22c)).

До do {...; euclid_sq = x*x + y*y; } while (euclid_sq >= 1.0); do {...; euclid_sq = x*x + y*y; } while (euclid_sq >= 1.0); явно проверяет расстояние, так что было бы правильно использовать sqrt(). : P (sqrt даже медленнее, чем div).

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

Если вы можете уменьшить вычисления до очень маленьких чисел, то математические операции FP потребуют ~ 120 дополнительных циклов, чтобы перейти в микрокод, когда операция с двумя нормальными числами приводит к денормализации. См. Agarch Fog microarch pdf для точных чисел и деталей. Это маловероятно, поскольку у вас много множителей, поэтому коэффициент масштабирования будет возведен в квадрат и уменьшен до 0,0. Я не вижу способа оправдать необходимое масштабирование некомпетентностью (даже дьявольской), только преднамеренной злобой.


Если вы можете использовать встроенные функции (<immintrin.h>)

Используйте movnti для movnti ваших данных из кеша. Дьявольский: он новый и слабо упорядоченный, так что процессор должен работать быстрее, верно? Или посмотрите этот связанный вопрос для случая, когда кто-то был в опасности сделать именно это (для разрозненных записей, где только в некоторых местах было жарко). clflush, вероятно, невозможно без злого умысла.

Используйте целочисленные тасования между математическими операциями FP, чтобы вызвать задержки обхода.

Смешение инструкций SSE и AVX без правильного использования vzeroupper приводит к большим vzeroupper в 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).

См. Также эксперимент Натана Курца с Intel math lib и glibc для этого кода. Будущий glibc будет поставляться с векторизованными реализациями exp() и так далее.


Если нацелен на pre-IvB или esp. Нехалем, попробуй заставить gcc вызвать частичные задержки в регистре с 16-битными или 8-битными операциями, за которыми следуют 32-битные или 64-битные операции. В большинстве случаев gcc будет использовать movzx после 8- или 16-битной операции, но в данном случае gcc изменяет значение ah а затем читает ax


С (встроенным) asm:

С помощью (встроенного) asm вы можете разбить кеш uop: 32-килобайтный фрагмент кода, который не помещается в три строки кеша 6uop, вызывает переключение с кеша uop на декодеры. Неприемлемый ALIGN использующий много однобайтовых nop вместо пары длинных nop на цели ветвления во внутреннем цикле, может помочь. Или поместите выравнивающий отступ после метки, а не до. : P Это имеет значение только в том случае, если внешний интерфейс является узким местом, чего не будет, если мы преуспеем в пессимизации остальной части кода.

Используйте самоизменяющийся код для запуска очистки конвейера (также называемой машинным ядром).

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


Распространенной идиомой для синхронизации является CPUID (для сериализации), а затем RDTSC. Время каждой итерации отдельно с CPUID/RDTSC чтобы убедиться, что RDTSC не переупорядочен с более ранними инструкциями, что сильно замедлит работу. (В реальной жизни разумный способ рассчитать время - это провести все итерации по времени, вместо того, чтобы рассчитывать каждую из них по отдельности и складывать их).


Вызывает много пропусков кэша и других замедлений памяти

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


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

Замените локальные переменные членами большой структуры, чтобы вы могли контролировать структуру памяти.

Используйте массивы в структуре для заполнения (и хранения случайных чисел, чтобы оправдать их существование).

Выберите свой макет памяти, чтобы все переходило в другую строку в том же "наборе" в кэше L1. Это только 8-сторонняя ассоциация, т.е. каждый набор имеет 8 "способов". Строки кэша 64B.

Более того, поместите вещи точно в 4096B, так как нагрузки имеют ложную зависимость от хранилищ на разных страницах, но с одинаковым смещением на странице. Агрессивные неупорядоченные процессоры используют устранение неоднозначности памяти, чтобы выяснить, когда можно переупорядочить загрузки и хранилища без изменения результатов, а реализация Intel имеет ложные срабатывания, которые предотвращают раннее начало загрузки. Возможно, они проверяют только биты ниже смещения страницы, поэтому проверка может начаться до того, как TLB переведет старшие биты с виртуальной страницы на физическую страницу. Как и руководство Агнера, см. Ответ Стивена Кэнона, а также раздел в конце ответа @Krazy Glew на тот же вопрос. (Энди Глеу был одним из архитекторов микроархитектуры P6 от Intel.)

Используйте __attribute__((packed)) чтобы позволить вам выровнять переменные так, чтобы они перекрывали строки кэша или даже границы страниц. (Таким образом, загрузка одного double требует данных из двух строк кэша). Неверно выровненные загрузки не имеют штрафов в любом Intel i7 uarch, за исключением случаев пересечения строк кэша и строк страницы. Разделение строк кэша все еще требует дополнительных циклов. Skylake значительно снижает штраф за загрузку страниц с 100 до 5 циклов. (Раздел 2.1.3). Возможно, связано с возможностью параллельного обхода двух страниц.

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

Если вы можете заставить компилятор использовать индексированные режимы адресации, это победит микроплавление. Возможно, используя #define для замены простых скалярных переменных на 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,...

Это то, что вы получите за доступ к двумерному массиву, например double rng_array[MAX_ROWS][512] в неправильном порядке (зацикливание строк, а не столбцов внутри строки во внутреннем цикле, как предложено @JesperJuhl). Если дьявольская некомпетентность может оправдать двумерный массив с такими размерами, то реальная некомпетентность садового разнообразия легко оправдывает зацикливание с неправильным шаблоном доступа. Это происходит в реальном коде в реальной жизни.

При необходимости измените границы цикла, чтобы использовать много разных страниц вместо повторного использования одних и тех же нескольких страниц, если массив не такой большой. Аппаратная предварительная выборка не работает (также/вообще) на всех страницах. Устройство предварительной выборки может отслеживать один прямой и один обратный поток на каждой странице (что здесь происходит), но будет действовать на него, только если пропускная способность памяти еще не заполнена без предварительной выборки.

Это также приведет к большому количеству пропусков TLB, если только страницы не будут объединены в огромную страницу (Linux делает это условно для анонимных (не поддерживаемых файлами) размещений, таких как malloc/new которые используют mmap(MAP_ANONYMOUS)).

Вместо массива для хранения списка результатов вы можете использовать связанный список. Тогда каждая итерация будет требовать загрузки с указателем (реальная опасность зависимости RAW для адреса загрузки следующей загрузки). При плохом распределителе вам, возможно, удастся разбросать узлы списка в памяти, победив кеш. С дьявольски некомпетентным распределителем он может поместить каждый узел в начало своей собственной страницы. (например, выделение с помощью mmap(MAP_ANONYMOUS) напрямую, без разбивки страниц или отслеживания размеров объектов для правильной поддержки free).


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

Несколько не по теме: заставить компилятор генерировать худший код/​​делать больше работы:

Используйте C++ 11 std::atomic<int> и std::atomic<double> для наиболее пессимального кода. MFENCE и lock инструкции выполняются довольно медленно, даже без конфликтов со стороны другого потока.

-m32 сделает код медленнее, потому что код x87 будет хуже, чем код SSE2. Основанное на стеке 32-битное соглашение о вызовах принимает больше инструкций и передает даже стеки FP в стеке таким функциям, как exp(). %23include+ //+-march=i386 just calls the libgcc helper functions, which were presumably compiled with the+default+-march / / -mi586 use a lock cmpxchg8b loop std::atomic ll(0); void foo_ll(void) { ll++%3B+} //+-mfpmath=sse isn!'t the+default on -m32 //gcc -m32!'s trick for atomic 64bit load/store is to use fild/fistp to bounce+data to the stack. //in this case, the bit pattern being moved is already FP+data, so it should just fld/fstp from+d+directly //reported as https://gcc.gnu.org/bugzilla/show_bug.cgi?id=71245 std::atomic+d(5.0); void foo_d(void) {%0A++d %3D++d + 1.0;%0A+//+d+=1.0%3B+//+unimplemented } std::atomic ld(5.0); void foo_ld(void) { ld %3D+ld + 1.0%3B+} ')),filterAsm:(commentOnly:!t,directives:!t,intel:!t,labels:!t),version:3 rel=noreferrer> atomic<uint64_t>::operator++ в -m32 требует lock cmpxchg8B цикла 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 использует 8-байтовый long double эквивалент, эквивалентный double. (В любом случае, загрузка/сохранение 10-байтовых (80-битных) операндов FP составляет 4/7 моп, по сравнению с float или double принимая только 1 моп каждый для fld m64/m32/fst) Принуждение x87 с long double поражениями автоматически векторизует даже для gcc -m64 -march=haswell -O3.

Если не используются atomic<uint64_t> циклов atomic<uint64_t>, используйте long double для всего, включая счетчики циклов.

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


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

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

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


Принудительное преобразование из целого числа в число с float и обратно. И/или double <=> float преобразования. Команды имеют задержку больше единицы, и скалярное значение int-> float (cvtsi2ss) плохо разработано, чтобы не cvtsi2ss остальную часть регистра xmm. (По этой причине gcc вставляет дополнительный pxor для разрыва зависимостей.)


Часто устанавливайте привязку вашего процессора к другому процессору (предложено @Egwor). дьявольские рассуждения: вы не хотите, чтобы одно ядро перегревалось при долгом запуске потока, не так ли? Возможно, переключение на другое ядро позволит этому ядру работать на более высокой тактовой частоте. (На самом деле: они настолько термически близки друг к другу, что это маловероятно, за исключением системы с несколькими разъемами). Теперь просто сделайте неправильную настройку и делайте это слишком часто. Помимо времени, потраченного на сохранение/восстановление состояния потока ОС, новое ядро имеет холодные кэши L2/L1, кэши UOP и предикторы ветвления.

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

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

Ответ 2

Несколько вещей, которые вы можете сделать, чтобы все работало как можно хуже:

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

  • везде используйте переменные std::atomic. Это сделает их очень дорогими из-за того, что компилятор вынужден вставлять барьеры памяти повсюду. И это то, что некомпетентный человек мог бы правдоподобно сделать, чтобы "обеспечить безопасность потока".

  • Удостоверьтесь, что доступ к памяти является наихудшим из возможных способов, которые может предсказать средство предварительной выборки (основной столбец или основной ряд).

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

  • убедитесь, что вся выделенная вами память выровнена очень странно и во что бы то ни стало избегайте выделения огромных страниц, так как это слишком эффективно для TLB.

  • что бы вы ни делали, не создавайте свой код с включенным оптимизатором компиляторов. И убедитесь, что вы включили наиболее выразительные символы отладки, которые вы можете (не заставит код работать медленнее, но это потратит дополнительное дисковое пространство).

Примечание. Этот ответ в основном просто суммирует мои комментарии, которые @Peter Cordes уже включил в свой очень хороший ответ. Предложите ему получить ваш голос, если у вас есть только один запасной :)

Ответ 3

Вы можете использовать long double для вычисления. На x86 это должен быть 80-битный формат. Только поддержка x87 FPU поддерживает это.

Немного недостатков x87 FPU:

  • Отсутствие SIMD, может потребоваться больше инструкций.
  • Основанный на стеке, проблематичный для суперскалярных и конвейерных архитектур.
  • Отдельный и довольно небольшой набор регистров, возможно, потребуется больше конверсий из других регистров и более операций с памятью.
  • В Core i7 есть 3 порта для SSE и только 2 для x87, процессор может выполнять меньше параллельных инструкций.

Ответ 4

Поздний ответ, но я не чувствую, что мы злоупотребляли связанными списками и TLB.

Используйте mmap для размещения ваших узлов, чтобы в основном использовать MSB адреса. Это должно привести к длительным цепочкам поиска TLB, страница - 12 бит, оставив 52 бита для перевода или примерно 5 уровней, которые он должен выполнять каждый раз. С некоторой удачей они должны каждый раз переходить в память для 5 уровней поиска и 1 доступ к памяти для доступа к вашему node, верхний уровень, скорее всего, будет в кеше где-то, поэтому мы можем надеяться на доступ к 5 * памяти. Поместите node так, чтобы он удалял худшую границу, чтобы чтение следующего указателя вызывало еще 3-4 поисковых перевода. Это может также полностью разрушить кеш из-за огромного количества поисков перевода. Также размер виртуальных таблиц может привести к тому, что большинство пользовательских данных будут выгружаться на диск за дополнительное время.

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