C для индексирования циклов: ускоренная перемотка вперед в новых процессорах?

В списке рассылки, на который я подписался, два хорошо осведомленных (IMO) программиста обсуждали некоторый оптимизированный код и говорили что-то вроде:

На процессорах, выпущенных 5-8 лет назад, было немного быстрее итерации для циклов назад (например, for (int i=x-1; i>=0; i--) {...}), потому что сравнение i с нолем более эффективно, чем сравнение его с некоторым другим числом. Но с очень недавними процессорами (например, с 2008-2009 гг.) Логика умозрительного загрузчика такова, что она работает лучше, если цикл for повторяется вперед (например, for (int i=0; i< x; i++) {...}).

Мой вопрос в том, что это правда? Недавно изменились ли реализации ЦП, так что теперь итерация в прямом цикле имеет преимущество перед обратным повторением? Если да, то для чего это объяснение? то есть что изменилось?

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

Ответы

Ответ 1

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

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

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

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

Взгляните на Руководство Intel по оптимизации для предварительных выборщиков оборудования. Есть четыре префешеры, перечисленные; два для NetBurst фишки:

  • NetBurst аппаратный предварительный выбор может обнаруживать потоки доступа к памяти в прямом или обратном направлениях, и он попытается загрузить данные из этих мест в кэш L2.
  • В NetBurst также есть префикс соседней кешированной строки (ACL), который автоматически загрузит две соседние строки кэша при первом приеме.

и два для Core:

  • Core имеет несколько более сложный аппаратный предварительный набор; он может обнаруживать чередующийся доступ в дополнение к потокам смежных ссылок, поэтому будет лучше, если вы пройдете через массив каждый другой элемент, каждый четвертый и т.д.
  • Core также имеет предварительный выбор ACL, например NetBurst.

Если вы выполняете итерацию по массиву вперед, вы создадите кучу последовательных, обычно смежных ссылок на память. Префиксеры ACL будут делать гораздо лучше для перекрестных контуров (потому что вы закончите использовать эти последующие строки кэша), чем для обратных циклов, но вы можете сделать ok, делая образы памяти назад, если префекты могут это обнаружить (как и в случае с аппаратными средствами предварительная выборка). Аппаратные предварительные выборщики в Core могут обнаруживать шаги, которые полезны для более сложных обходов массивов.

Эти простые эвристики могут привести вас к неприятностям в некоторых случаях. Например, Intel на самом деле рекомендует отключить предварительную выборку соседних кеш-строк для серверов, поскольку они, как правило, делают более случайные ссылки на память, чем настольные пользовательские машины. Вероятность не использовать соседнюю строку кэша выше на сервере, поэтому получение данных, которые вы на самом деле не собираетесь использовать, в конечном итоге загрязняет ваш кеш (заполняя его нежелательными данными), а производительность страдает. Более подробно об этой проблеме см. Статью Суперкомпьютер 2009 на с использованием машинного обучения для настройки префетов в больших дата-центрах. Некоторые ребята из Google находятся на этой бумаге; производительность - это то, что очень важно для них.

Простая эвристика не поможет вам с более сложными алгоритмами, и вам, возможно, придется подумать о размерах ваших кэшей L1, L2 и т.д. Например, обработка изображений часто требует, чтобы вы выполняли некоторые операции над подразделами 2D-изображения, но порядок, по которому вы перемещаете изображение, может повлиять на то, насколько хорошо его можно найти в кеше, не вытесняя его. Взгляните на обход Z-порядка и loop tiling если вас интересует такая штука. Это довольно простой пример сопоставления 2D-местоположения данных изображения с 1D-областью памяти для повышения производительности. Это также область, в которой компиляторы не всегда способны наилучшим образом перестроить ваш код, но ручная реструктуризация вашего кода C может значительно улучшить производительность кэша.

Надеюсь, это даст вам представление о том, как порядок итераций влияет на производительность памяти. Это зависит от конкретной архитектуры, но идеи общие. Вы должны уметь понимать prefetching на AMD и Power, если вы можете понять это на Intel, и вам не нужно знать сборки для структурирования вашего кода, чтобы использовать память. Вам просто нужно знать небольшую компьютерную архитектуру.

Ответ 2

Я не знаю. Но я знаю, как написать быстрый ориентир без каких-либо гарантий научной достоверности (фактически, с довольно строгими гарантиями недействительности). Он имеет интересные результаты:

#include <time.h>
#include <stdio.h>

int main(void)
{
    int i;
    int s;
    clock_t start_time, end_time;
    int centiseconds;

    start_time = clock();
    s = 1;
    for (i = 0; i < 1000000000; i++)
    {
        s = s + i;
    }
    end_time = clock();
    centiseconds = (end_time - start_time)*100 / CLOCKS_PER_SEC;
    printf("Answer is %d; Forward took %ld centiseconds\n", s, centiseconds);

    start_time = clock();
    s = 1;
    for (i = 999999999; i >= 0; i--)
    {
        s = s + i;
    }
    end_time = clock();
    centiseconds = (end_time - start_time)*100 / CLOCKS_PER_SEC;
    printf("Answer is %d; Backward took %ld centiseconds\n", s, centiseconds);

    return 0;
}

Скомпилирован с -O9 с использованием gcc 3.4.4 на Cygwin, работающий на процессоре AMD Athlon (tm) 64 3500+ "(2211 МГц) в 32-разрядной версии Windows XP:

Answer is -1243309311; Forward took 93 centiseconds
Answer is -1243309311; Backward took 92 centiseconds

(Ответы различаются по 1 в любом случае несколькими повторениями.)

Скомпилирован с -I9, используя gcc 4.4.1, работающий на процессоре Intel (R) Atom N270 @1,60 ГГц (800 МГц и, предположительно, только одно ядро, учитывая программу) в 32-разрядном Ubuntu Linux.

Answer is -1243309311; Forward took 196 centiseconds
Answer is -1243309311; Backward took 228 centiseconds

(Ответы различаются по 1 в любом случае несколькими повторениями.)

Глядя на код, прямой цикл преобразуется в:

; Gcc 3.4.4 on Cygwin for Athlon      ; Gcc 4.4.1 on Ubuntu for Atom
L5:                                .L2:
    addl    %eax, %ebx                 addl    %eax, %ebx
    incl    %eax                       addl    $1, %eax
    cmpl    $999999999, %eax           cmpl    $1000000000, %eax
    jle     L5                         jne     .L2

Обратное:

L9:                                .L3:
    addl    %eax, %ebx                 addl    %eax, %ebx
    decl    %eax                       subl    $1, $eax
    jns     L9                         cmpl    $-1, %eax
                                       jne .L3

Что показывает, если не намного больше, что поведение GCC изменилось между этими двумя версиями!

Вставка старых циклов GCC в новый файл asm GCC дает результаты:

Answer is -1243309311; Forward took 194 centiseconds
Answer is -1243309311; Backward took 133 centiseconds

Резюме: нa > 5-летнем Athlon, петли, созданные GCC 3.4.4, имеют одинаковую скорость. На новом (< 1 год?) Atom обратный цикл выполняется значительно быстрее. GCC 4.4.1 имеет небольшой регресс для этого конкретного случая, который лично мне не беспокоит, по крайней мере, с учетом его. (Я должен был убедиться, что s используется после цикла, потому что в противном случае компилятор полностью завершил бы вычисление.)

[1] Я никогда не помню команду для системной информации...

Ответ 3

Да. но с оговоркой. Идея о том, что цикличность назад быстрее никогда не применяется ко всем старым процессорам. Это x86 вещь (как в 8086 по 486, возможно, Pentium, хотя я не думаю, что дальше).

Эта оптимизация никогда не применялась ни к какой другой архитектуре процессора, о которой я знаю.

Вот почему.

У 8086 был регистр, специально оптимизированный для использования в качестве счетчика циклов. Вы помещаете счетчик циклов в CX, а затем есть несколько инструкций, которые уменьшают CX, а затем устанавливают коды условий, если они обращаются к нулю. На самом деле был префикс инструкции, который вы могли бы поставить перед другими инструкциями (префикс REP), который в основном перебирал другую команду до тех пор, пока CX не достигнет 0.

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

Но это было давно. С тех пор, как Pentium, эти сложные инструкции были медленнее, чем использование большего количества и более простые инструкции. (RISC baby!). Ключевым моментом, который мы пытаемся сделать в эти дни, является попытка установить время между загрузкой регистра и его использованием, потому что конвейеры могут выполнять несколько операций за каждый цикл, если вы не пытаетесь использовать один и тот же регистр для более чем одной вещи за раз.

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

Ответ 4

Я наткнулся на этот вопрос, наблюдая значительное снижение производительности при повторении массива назад и вперед. Я боялся, что это будет prefetcher, но ответы выше убедили меня, что это не так. Затем я исследовал далее и выяснил, что похоже, что GCC (4.8.4) не может использовать полную мощность операций SIMD в обратных циклах.

Фактически, скомпилировав следующий код (здесь) с помощью -S -O3 -mavx:

  for (i = 0; i < N; ++i)
    r[i] = (a[i] + b[i]) * c[i];

приводит к существенно:

.L10:
    addl    $1, %edx
    vmovupd (%rdi,%rax), %xmm1
    vinsertf128     $0x1, 16(%rdi,%rax), %ymm1, %ymm1
    vmovupd (%rsi,%rax), %xmm0
    vinsertf128     $0x1, 16(%rsi,%rax), %ymm0, %ymm0
    vaddpd  (%r9,%rax), %ymm1, %ymm1
    vmulpd  %ymm0, %ymm1, %ymm0
    vmovupd %xmm0, (%rcx,%rax)
    vextractf128    $0x1, %ymm0, 16(%rcx,%rax)
    addq    $32, %rax
    cmpl    %r8d, %edx
    jb      .L10

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

И наоборот, следующий код, скомпилированный с теми же параметрами:

  for (i = 0; i < N; ++i)
    r[N-1-i] = (a[N-1-i] + b[N-1-i]) * c[N-1-i];

дает:

.L5:
    vmovsd  a+79992(%rax), %xmm0
    subq    $8, %rax
    vaddsd  b+80000(%rax), %xmm0, %xmm0
    vmulsd  c+80000(%rax), %xmm0, %xmm0
    vmovsd  %xmm0, r+80000(%rax)
    cmpq    $-80000, %rax
    jne     .L5

который выполняет только одну двойную операцию (vaddsd, vmulsd).

Этот факт сам по себе может отвечать за фактор 4 между производительностью при повторении назад и вперед.

Используя -ftree-vectorizer-verbose=2, похоже, что проблема сохраняется в обратном порядке: "отрицательный шаг для хранения". Фактически, если a, b и c считываются назад, но r записывается в прямом направлении, код снова векторизован.

Ответ 5

Нет, мы не можем сказать, что реализации ЦП были изменены, чтобы ускорить цикл. И это очень мало связано с самими процессорами.

Это связано с тем, что вы не указали, какой процессор вы говорите, и какой компилятор.

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

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

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

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

for (i = 0; i < 1000; i++) { process (a[i]); }

а не:

for (i = 999; i >= 0; i--) { process (a[999-i]); }

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

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

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

Ответ 6

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

Ответ 7

Вероятно, это не делает разницу в скорости, но я часто пишу:

for (i = n; --i >= 0; ) blah blah

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

Конечно, отвечая на этот вопрос, я рискую утверждать, что это важно. Это вопрос микрооптимизации, который тесно связан с преждевременной оптимизацией, и все говорят, что вы не должны этого делать, но тем не менее SO наводнен ею.