Когда, если вообще когда-либо, развертывание цикла все еще полезно?

93

Я пытался оптимизировать какой-то чрезвычайно критичный для производительности код (алгоритм быстрой сортировки, который вызывается миллионы и миллионы раз в симуляции Монте-Карло) путем развертывания цикла. Вот внутренний цикл, который я пытаюсь ускорить:

// Search for elements to swap.
while(myArray[++index1] < pivot) {}
while(pivot < myArray[--index2]) {}

Я пробовал развернуть что-то вроде:

while(true) {
    if(myArray[++index1] < pivot) break;
    if(myArray[++index1] < pivot) break;
    // More unrolling
}


while(true) {
    if(pivot < myArray[--index2]) break;
    if(pivot < myArray[--index2]) break;
    // More unrolling
}

Это не имело абсолютно никакого значения, поэтому я вернул его в более читаемую форму. У меня был аналогичный опыт, когда я пробовал развернуть цикл. Учитывая качество предикторов ветвления на современном оборудовании, когда, если вообще, разворачивание цикла все еще является полезной оптимизацией?

дсимча
источник
1
Могу я спросить, почему вы не используете стандартные библиотечные процедуры быстрой сортировки?
Питер Александр
14
@Poita: Потому что у меня есть некоторые дополнительные функции, которые мне нужны для статистических вычислений, которые я делаю, и они очень хорошо настроены для моих вариантов использования и, следовательно, менее общие, но заметно быстрее, чем стандартная библиотека. Я использую язык программирования D, в котором есть старый дрянной оптимизатор, и для больших массивов случайных чисел с плавающей запятой я все еще опережаю сортировку GCC C ++ STL на 10-20%.
dsimcha

Ответы:

122

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

Простой пример:

for (int i=0; i<n; i++)
{
  sum += data[i];
}

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

С другой стороны, этот код:

for (int i=0; i<n; i+=4)
{
  sum1 += data[i+0];
  sum2 += data[i+1];
  sum3 += data[i+2];
  sum4 += data[i+3];
}
sum = sum1 + sum2 + sum3 + sum4;

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

Нильс Пипенбринк
источник
2
Спасибо. Я пробовал разворачивать цикл в этом стиле в нескольких других местах библиотеки, где я вычисляю суммы и прочее, и в этих местах это творит чудеса. Я почти уверен, что причина в том, что это увеличивает параллелизм на уровне инструкций, как вы предлагаете.
dsimcha 03
2
Хороший ответ и поучительный пример. Хотя я не вижу, как задержки из-за промахов в кэше могут повлиять на производительность для этого конкретного примера . Я пришел, чтобы объяснить себе разницу в производительности между двумя частями кода (на моей машине второй фрагмент кода в 2–3 раза быстрее), отметив, что первый отключает любой тип параллелизма на уровне команд в полосах с плавающей запятой. Второй позволит суперскалярному процессору выполнять до четырех операций добавления с плавающей запятой одновременно.
Toby Brull
2
Помните, что при вычислении суммы таким образом результат не будет численно идентичен исходному циклу.
Барабас
Зависимость с переносом цикла - это один цикл , сложение. Ядро OoO подойдет. Здесь развертывание может помочь SIMD с плавающей запятой, но это не касается OoO.
Veedrac
2
@ Нильс: Не очень; массовые процессоры x86 OoO по-прежнему достаточно похожи на Core2 / Nehalem / K10. Догонять после промаха в кэше было по-прежнему довольно незначительно, а сокрытие задержки FP оставалось главным преимуществом. В 2010 году процессоры, которые могли выполнять 2 загрузки за такт, были еще реже (просто AMD, потому что SnB еще не был выпущен), поэтому несколько аккумуляторов были определенно менее ценными для целочисленного кода, чем сейчас (конечно, это скалярный код, который должен автоматически векторизоваться. , так что кто знает, превратят ли компиляторы несколько аккумуляторов в векторные элементы или в несколько векторных аккумуляторов ...)
Питер Кордес
25

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

for (int i=0; i<200; i++) {
  doStuff();
}

записывать:

for (int i=0; i<50; i++) {
  doStuff();
  doStuff();
  doStuff();
  doStuff();
}

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

Однако ручное развертывание цикла в целом во многом является артефактом истории. Это еще один из постоянно растущего списка вещей, которые хороший компилятор сделает за вас, когда это необходимо. Например, большинство людей не заботятся о том, чтобы писать x <<= 1или x += xвместо x *= 2. Вы просто пишете, x *= 2а компилятор оптимизирует его для вас, как лучше.

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

Cletus
источник
1
@Mike Конечно, отключение оптимизации, если это хорошая идея, когда вы озадачены, но стоит прочитать ссылку, которую опубликовал Poita_. Компиляторы очень хорошо справляются с этим делом.
dmckee --- котенок экс-модератора
16
@Mike "Я вполне способен решать, когда или когда не делать эти вещи" ... Я в этом сомневаюсь, если только ты не сверхчеловек.
Мистер Бой
5
@ Джон: Я не знаю, почему ты так говоришь; люди, кажется, думают, что оптимизация - это своего рода черное искусство, которое умеют делать только компиляторы и хорошие гадалки. Все сводится к инструкциям, циклам и причинам, по которым они тратятся. Как я много раз объяснял на SO, легко сказать, как и почему они тратятся. Если у меня есть цикл, который должен использовать значительный процент времени, и он тратит слишком много циклов на накладные расходы цикла по сравнению с содержимым, я могу это увидеть и развернуть. То же самое для подъема кода. Для этого не нужен гений.
Майк Данлэви
3
Я уверен, что это не так сложно, но все же сомневаюсь, что вы сможете сделать это так же быстро, как компилятор. В чем проблема с компилятором, который делает это за вас? Если вам это не нравится, просто отключите оптимизацию и сжигайте свое время, как будто это 1990 год!
Mr. Boy
2
Прирост производительности из-за развертывания цикла не имеет ничего общего с сохраняемыми вами сравнениями. Вообще ничего.
bobbogo 01
14

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

Было бы полезно узнать, сколько оптимизаций делает за вас ваш компилятор.

Я нашел презентацию Феликса фон Лейтнера очень поучительной по этому вопросу. Рекомендую прочитать. Резюме: Современные компиляторы ОЧЕНЬ умны, поэтому ручная оптимизация почти никогда не бывает эффективной.

Питер Александр
источник
7
Это хорошее прочтение, но единственная часть, которая, как мне показалось, была уместна, - это то, что он говорит о сохранении простой структуры данных. Остальное было точным, но основывалось на гигантском невысказанном предположении - то, что выполняется , должно быть. В процессе настройки я обнаруживаю, что люди беспокоятся о пропусках регистров и кеша, когда огромное количество времени уходит на ненужные горы кода абстракции.
Майк Данлэйви,
4
«ручная оптимизация почти никогда не бывает эффективной» → Возможно, это правда, если вы новичок в этой задаче. В противном случае просто неправда.
Veedrac
В 2019 году я все еще выполнял ручную развертку со значительным преимуществом по сравнению с автоматическими попытками компилятора ... так что не так надежно позволить компилятору делать все это. Кажется, разворачивается не так уж и часто. По крайней мере, для C # я не могу говорить от имени всех языков.
WDUK
2

Насколько я понимаю, современные компиляторы уже разворачивают циклы там, где это необходимо - примером является gcc, если переданы флаги оптимизации, о которых говорится в руководстве, он будет:

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

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

Рич Брэдшоу
источник
Как правило, вовремя компиляторы не развертывают цикл, эвристика слишком дорога. Статические компиляторы могут тратить на это больше времени, но разница между двумя доминирующими способами важна.
Abel
2

Развертывание цикла, будь то развертывание вручную или развертывание компилятора, часто может быть контрпродуктивным, особенно с более новыми процессорами x86 (Core 2, Core i7). Итог: сравните свой код с развертыванием цикла и без него на любых процессорах, на которых вы планируете развернуть этот код.

Пол Р
источник
Почему именно на новых процессорах x86?
JohnTortugo
7
@JohnTortugo: Современные процессоры x86 имеют определенную оптимизацию для небольших циклов - см., Например, Loop Stream Detector на архитектурах Core и Nehalem - развертывание цикла, чтобы он больше не был достаточно мал, чтобы поместиться в кеш LSD, побеждает эту оптимизацию. См., Например, tomshardware.com/reviews/Intel-i7-nehalem-cpu,2041-3.html
Paul R
1

Пытаться, не зная, - это не способ сделать это.
Эта сортировка занимает много времени?

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

Вот пример того, как добиться максимальной производительности.

Майк Данлэйви
источник
1

В определенных случаях может быть полезно разворачивание петли. Единственное преимущество - не пропуск некоторых тестов!

Например, он может позволить скалярную замену, эффективную вставку программной предварительной выборки ... Вы будете удивлены, насколько это может быть полезно (вы можете легко получить 10% ускорение в большинстве циклов даже с -O3), агрессивно разворачивая.

Как было сказано ранее, это во многом зависит от цикла и компилятора, и эксперимент необходим. Трудно составить правило (или эвристика компилятора для развертывания была бы идеальной)

Камчатка
источник
0

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

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

Jwendl
источник
Я разворачивал быструю сортировку, которая вызывается из внутреннего цикла моей симуляции, а не из основного цикла симуляции.
dsimcha
0

Развертывание цикла по-прежнему полезно, если в цикле и вместе с ним много локальных переменных. Для повторного использования этих регистров вместо сохранения одного для индекса цикла.

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

Сравнение (до конца цикла) также является серьезным недостатком, если сравнение тяжелое (то есть не связанное с testинструкциями), особенно если оно зависит от внешней функции.

Развертывание цикла также помогает повысить осведомленность ЦП о предсказании ветвлений, но это все равно происходит.

ЛираНуна
источник