Как попросить GCC полностью развернуть этот цикл (т.е. очистить этот цикл)?

Есть ли способ указать GCC (я использую 4.8.4) развернуть цикл while в нижней функции полностью, то есть очистить этот цикл? Количество итераций цикла известно во время компиляции: 58.

Позвольте мне сначала объяснить, что я пробовал.

Проверяя выход ГАЗА:

gcc -fpic -O2 -S GEPDOT.c

Используются 12 регистров XMM0 - XMM11. Если я передам флаг -funroll-loops в gcc:

gcc -fpic -O2 -funroll-loops -S GEPDOT.c

цикл разворачивается только два раза. Я проверил параметры оптимизации GCC. GCC сообщает, что -funroll-loops также включит -frename-registers, поэтому, когда GCC разворачивает цикл, его предварительный выбор для распределения регистров - использовать оставшиеся регистры. Но осталось только 4 XMM12 - XMM15, поэтому GCC может развернуться только 2 раза в лучшем случае. Если бы было 48 вместо 16 доступных регистров XMM, GCC без проблем развернул бы цикл while 4 раза.

Но я провел еще один эксперимент. Сначала я дважды развернул цикл while вручную, получив функцию GEPDOT_2. Тогда нет никакой разницы между

gcc -fpic -O2 -S GEPDOT_2.c

а также

gcc -fpic -O2 -funroll-loops -S GEPDOT_2.c

Поскольку GEPDOT_2 уже израсходовали все регистры, развертывание не выполняется.

GCC регистрирует переименование, чтобы избежать появления потенциальной ложной зависимости. Но я точно знаю, что в моем GEPDOT такого потенциала не будет; даже если есть, это не важно. Я сам пробовал развернуть петлю, и развертка в 4 раза быстрее, чем развертка в 2 раза, быстрее, чем без развертывания. Конечно, я могу вручную развернуть еще несколько раз, но это утомительно. Может ли GCC сделать это за меня? Спасибо.

// C file "GEPDOT.c"
#include <emmintrin.h>

void GEPDOT (double *A, double *B, double *C) {
  __m128d A1_vec = _mm_load_pd(A); A += 2;
  __m128d B_vec = _mm_load1_pd(B); B++;
  __m128d C1_vec = A1_vec * B_vec;
  __m128d A2_vec = _mm_load_pd(A); A += 2;
  __m128d C2_vec = A2_vec * B_vec;
  B_vec = _mm_load1_pd(B); B++;
  __m128d C3_vec = A1_vec * B_vec;
  __m128d C4_vec = A2_vec * B_vec;
  B_vec = _mm_load1_pd(B); B++;
  __m128d C5_vec = A1_vec * B_vec;
  __m128d C6_vec = A2_vec * B_vec;
  B_vec = _mm_load1_pd(B); B++;
  __m128d C7_vec = A1_vec * B_vec;
  A1_vec = _mm_load_pd(A); A += 2;
  __m128d C8_vec = A2_vec * B_vec;
  B_vec = _mm_load1_pd(B); B++;
  int k = 58;
  /* can compiler unroll the loop completely (i.e., peel this loop)? */
  while (k--) {
    C1_vec += A1_vec * B_vec;
    A2_vec = _mm_load_pd(A); A += 2;
    C2_vec += A2_vec * B_vec;
    B_vec = _mm_load1_pd(B); B++;
    C3_vec += A1_vec * B_vec;
    C4_vec += A2_vec * B_vec;
    B_vec = _mm_load1_pd(B); B++;
    C5_vec += A1_vec * B_vec;
    C6_vec += A2_vec * B_vec;
    B_vec = _mm_load1_pd(B); B++;
    C7_vec += A1_vec * B_vec;
    A1_vec = _mm_load_pd(A); A += 2;
    C8_vec += A2_vec * B_vec;
    B_vec = _mm_load1_pd(B); B++;
    }
  C1_vec += A1_vec * B_vec;
  A2_vec = _mm_load_pd(A);
  C2_vec += A2_vec * B_vec;
  B_vec = _mm_load1_pd(B); B++;
  C3_vec += A1_vec * B_vec;
  C4_vec += A2_vec * B_vec;
  B_vec = _mm_load1_pd(B); B++;
  C5_vec += A1_vec * B_vec;
  C6_vec += A2_vec * B_vec;
  B_vec = _mm_load1_pd(B);
  C7_vec += A1_vec * B_vec;
  C8_vec += A2_vec * B_vec;
  /* [write-back] */
  A1_vec = _mm_load_pd(C); C1_vec = A1_vec - C1_vec;
  A2_vec = _mm_load_pd(C + 2); C2_vec = A2_vec - C2_vec;
  A1_vec = _mm_load_pd(C + 4); C3_vec = A1_vec - C3_vec;
  A2_vec = _mm_load_pd(C + 6); C4_vec = A2_vec - C4_vec;
  A1_vec = _mm_load_pd(C + 8); C5_vec = A1_vec - C5_vec;
  A2_vec = _mm_load_pd(C + 10); C6_vec = A2_vec - C6_vec;
  A1_vec = _mm_load_pd(C + 12); C7_vec = A1_vec - C7_vec;
  A2_vec = _mm_load_pd(C + 14); C8_vec = A2_vec - C8_vec;
  _mm_store_pd(C,C1_vec); _mm_store_pd(C + 2,C2_vec);
  _mm_store_pd(C + 4,C3_vec); _mm_store_pd(C + 6,C4_vec);
  _mm_store_pd(C + 8,C5_vec); _mm_store_pd(C + 10,C6_vec);
  _mm_store_pd(C + 12,C7_vec); _mm_store_pd(C + 14,C8_vec);
  }

обновление 1

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

Я лично считаю, что надежный способ - сначала закодировать тело цикла (которое является ключом к HPC) во встроенной сборке asm, а затем дублировать его столько раз, сколько я захочу. Ранее в этом году у меня был непопулярный пост: встроенная сборка < / а>. Код был немного другим, потому что количество итераций цикла j является аргументом функции, поэтому неизвестно во время компиляции. В этом случае я не могу полностью развернуть цикл, поэтому я только дважды продублировал код сборки и преобразовал цикл в метку и прыжок. Оказалось, что результирующая производительность моей написанной сборки примерно на 5% выше, чем производительность сборки, созданной компилятором, что может указывать на то, что компилятор не может выделить регистры ожидаемым и оптимальным образом.

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

  1. Встроенная сборка asm подверглась критике за непереносимость. Хотя не понимаю почему. Может из-за того, что на разных машинах засорены разные регистры?
  2. Компилятор тоже становится лучше. Так что я бы по-прежнему предпочел алгоритмическую оптимизацию и лучшую привычку кодирования C, чтобы помочь компилятору генерировать хороший результат;
  3. Последняя причина более важна. Количество итераций не всегда может быть 58. Я разрабатываю высокопроизводительную подпрограмму факторизации матрицы. Для коэффициента блокировки кеша nb количество итераций будет nb-2. Я не собираюсь использовать nb в качестве аргумента функции, как это было в предыдущем посте. Это машинно-зависимый параметр, который будет определен как макрос. Таким образом, количество итераций известно во время компиляции, но может варьироваться от машины к машине. Угадайте, сколько утомительной работы мне нужно проделать при ручном развертывании цикла для множества nb. Так что, если есть способ просто указать компилятору отсоединить цикл, это прекрасно.

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


person Zheyuan Li    schedule 20.03.2016    source источник
comment
Вы пробовали -funroll-all-loops?   -  person Nate Eldredge    schedule 20.03.2016
comment
Итак, если вы вручную продублируете тело этого цикла 58 раз, сможет ли GCC достойно справиться с использованием регистров? Я спрашиваю, потому что кажется достаточно простым написать препроцессор, который развернет цикл. Например, замените while на preproc__repeat(58). Затем напишите препроцессор, который ищет preproc__repeat, извлекает число и дублирует тело указанное количество раз.   -  person user3386109    schedule 20.03.2016
comment
1) Разные процессоры не просто сбивают разные регистры. У них даже нет одинаковых регистров. И у них нет одинаковых инструкций (хотя _mm_load1_pd уже будет в некоторой степени зависеть от процессора). Кроме того, разные компиляторы по-разному обрабатывают встроенные инструкции asm. Встроенный asm, который работает с одним компилятором, может компилироваться, но не может дать правильные результаты для другого.   -  person David Wohlferd    schedule 20.03.2016
comment
Закрывать. Большинство компиляторов c имеют некоторую форму встраивания asm. Однако не существует стандарта их семантики. Один компилятор может сбить все регистры после вызова asm на всякий случай. Базовый asm Gcc НИКОГДА не сбивает с толку. gcc также не выполняет затирание памяти для базового asm. , но другие компиляторы делают. Это может привести к тонким различиям между компиляторами. Я не знаю, что вы имеете в виду под «доминирующей архитектурой». x86 сегодня очень распространен, но ARM - тоже. А что будет с вашим asm, если Intel добавит в i8 еще xmm regs?   -  person David Wohlferd    schedule 20.03.2016
comment
Как вы думаете, почему можно полностью развернуть этот цикл? Код может быть медленнее, если он полностью развернут, из-за кэширования µop, которое в противном случае присутствует.   -  person fuz    schedule 20.03.2016
comment
@AlphaBetaGamma Эта статья старая, тогда не существовало кеш-памяти µop. См. Мой ответ о том, как полностью развернуть цикл.   -  person fuz    schedule 20.03.2016
comment
Если предполагается, что это скалярное произведение 4 × 4 для векторов длиной 256, код выглядит подозрительно. Например, вы увеличиваете указатель A на 6 + 58 × 4 = 238 элементов. Вы сравнивали результаты с наивной реализацией, чтобы убедиться, что ваши вычисления верны, прежде чем начинать их оптимизацию? Я не лукавлю: я должен постоянно (модульно) тестировать каждую версию моих оптимизированных функций самостоятельно, иначе у меня получится не исправляемый код, поскольку ошибки накладываются друг на друга ...   -  person Nominal Animal    schedule 20.03.2016
comment
Ok. Если вам интересно, я добавил несколько примеров реализации того, как я вычисляю c = a × b + c , где a - это 4-строчная, n -столбцовая матрица, смежная, но транспонированная в память (другими словами, в порядке по столбцам), b - это n -строчная матрица с 4 столбцами, непрерывная в памяти (в порядке старших строк), а c - это 4-строчная, 4-столбцовая матрица также непрерывна в памяти (в порядке старших строк). Одна версия предназначена для SSE2 / SSE3, а другая - для AVX / AVX2.   -  person Nominal Animal    schedule 20.03.2016


Ответы (2)


Попробуйте настроить параметры оптимизатора:

gcc -O3 -funroll-loops --param max-completely-peeled-insns=1000 --param max-completely-peel-times=100

Это должно помочь.

person fuz    schedule 20.03.2016
comment
@AlphaBetaGamma Вы можете попробовать поэкспериментировать с флагами. Если я правильно помню, для работы -funroll-loops потребуется как минимум -O1. Когда я компилирую с -mavx, распределение регистров намного лучше. Если вы замените его встроенной сборкой, он все равно должен развернуться, но я не эксперт в том, как работает gcc. - person fuz; 20.03.2016
comment
@AlphaBetaGamma С -mavx компилятор выдает инструкции с тремя операндами вместо инструкций с двумя операндами. Это отменяет все инструкции по перемещению. Я предполагаю, что когда не осталось ходов, распределение регистров является оптимальным. - person fuz; 20.03.2016
comment
@AlphaBetaGamma AVX определяет новую кодировку инструкций с тремя операндами. Это также относится к регистрам xmm, но не имеет обратной совместимости, поэтому должно быть явно включено. - person fuz; 20.03.2016
comment
@AlphaBetaGamma Кэш µop кэширует декодированные инструкции, декодер - одна из самых медленных частей ЦП, так что пропускать его приятно. Он лучше всего работает с очень маленькими циклами (27 мкопс или меньше), разворачивание цикла отменяет его, поскольку цикл становится слишком большим, чтобы поместиться в кеш. Пара cmp / jnz настолько дешевая, что никак не влияет на производительность. Это может быть даже бесплатно. Но как всегда: тест, когда сомневаешься. - person fuz; 20.03.2016
comment
@AlphaBetaGamma Нет! Правильно предсказанный переход не очищает конвейер, и условная инструкция в цикле будет предсказана правильно (это то, для чего оптимизировано предсказание ветвления). Кэш µop кэширует микрокод, то есть уже декодированные инструкции. Вот почему это так полезно. Хотя он очень маленький. Развертывание цикла не так полезно в качестве оптимизации сегодня, как 20 лет назад. - person fuz; 20.03.2016
comment
@AlphaBetaGamma В кэше L1 хранится машинный код или данные. Он не хранит декодированные инструкции, но может хранить границы инструкций для параллельного декодирования. Но в остальном да. - person fuz; 20.03.2016

Это не ответ, но он может быть интересен другим, пытающимся векторизовать умножение матриц с помощью GCC.

Ниже я предполагаю, что c - это матрица 4 × 4 в порядке старших строк, a - это матрица с 4 столбцами n в порядок основных столбцов (транспонированный), b - это матрица с 4 столбцами, n строк в порядке основных строк, а операция для вычисления - c < / em> = a × b + c, где × обозначает умножение матриц.

Наивная функция для достижения этого -

void slow_4(double       *c,
            const double *a,
            const double *b,
            size_t        n)
{
    size_t row, col, i;

    for (row = 0; row < 4; row++)
        for (col = 0; col < 4; col++)
            for (i = 0; i < n; i++)
                c[4*row+col] += a[4*i+row] * b[4*i+col];
}

GCC генерирует довольно хороший код для SSE2 / SSE3, используя

#if defined(__SSE2__) || defined(__SSE3__)

typedef  double  vec2d  __attribute__((vector_size (2 * sizeof (double))));

void fast_4(vec2d       *c,
            const vec2d *a,
            const vec2d *b,
            size_t       n)
{
    const vec2d *const b_end = b + 2L * n;

    vec2d s00 = c[0];
    vec2d s02 = c[1];
    vec2d s10 = c[2];
    vec2d s12 = c[3];
    vec2d s20 = c[4];
    vec2d s22 = c[5];
    vec2d s30 = c[6];
    vec2d s32 = c[7];

    while (b < b_end) {
        const vec2d b0 = b[0];
        const vec2d b2 = b[1];
        const vec2d a0 = { a[0][0], a[0][0] };
        const vec2d a1 = { a[0][1], a[0][1] };
        const vec2d a2 = { a[1][0], a[1][0] };
        const vec2d a3 = { a[1][1], a[1][1] };
        s00 += a0 * b0;
        s10 += a1 * b0;
        s20 += a2 * b0;
        s30 += a3 * b0;
        s02 += a0 * b2;
        s12 += a1 * b2;
        s22 += a2 * b2;
        s32 += a3 * b2;
        b += 2;
        a += 2;
    }

    c[0] = s00;
    c[1] = s02;
    c[2] = s10;
    c[3] = s12;
    c[4] = s20;
    c[5] = s22;
    c[6] = s30;
    c[7] = s32; 
}

#endif

Для AVX GCC может работать еще лучше с

#if defined(__AVX__) || defined(__AVX2__)

typedef  double  vec4d  __attribute__((vector_size (4 * sizeof (double))));

void fast_4(vec4d       *c,
            const vec4d *a,
            const vec4d *b,
            size_t       n)
{
    const vec4d *const b_end = b + n;

    vec4d s0 = c[0];
    vec4d s1 = c[1];
    vec4d s2 = c[2];
    vec4d s3 = c[3];

    while (b < b_end) {
        const vec4d bc = *(b++);
        const vec4d ac = *(a++);
        const vec4d a0 = { ac[0], ac[0], ac[0], ac[0] };
        const vec4d a1 = { ac[1], ac[1], ac[1], ac[1] };
        const vec4d a2 = { ac[2], ac[2], ac[2], ac[2] };
        const vec4d a3 = { ac[3], ac[3], ac[3], ac[3] };
        s0 += a0 * bc;
        s1 += a1 * bc;
        s2 += a2 * bc;
        s3 += a3 * bc;
    }

    c[0] = s0;
    c[1] = s1;
    c[2] = s2;
    c[3] = s3;
}

#endif

Версия SSE3 сгенерированной сборки с использованием gcc-4.8.4 (-O2 -march=x86-64 -mtune=generic -msse3) по сути

fast_4:
        salq    $5, %rcx
        movapd  (%rdi), %xmm13
        addq    %rdx, %rcx
        cmpq    %rcx, %rdx
        movapd  16(%rdi), %xmm12
        movapd  32(%rdi), %xmm11
        movapd  48(%rdi), %xmm10
        movapd  64(%rdi), %xmm9
        movapd  80(%rdi), %xmm8
        movapd  96(%rdi), %xmm7
        movapd  112(%rdi), %xmm6
        jnb     .L2
.L3:
        movddup (%rsi), %xmm5
        addq    $32, %rdx
        movapd  -32(%rdx), %xmm1
        addq    $32, %rsi
        movddup -24(%rsi), %xmm4
        movapd  %xmm5, %xmm14
        movddup -16(%rsi), %xmm3
        movddup -8(%rsi), %xmm2
        mulpd   %xmm1, %xmm14
        movapd  -16(%rdx), %xmm0
        cmpq    %rdx, %rcx
        mulpd   %xmm0, %xmm5
        addpd   %xmm14, %xmm13
        movapd  %xmm4, %xmm14
        mulpd   %xmm0, %xmm4
        addpd   %xmm5, %xmm12
        mulpd   %xmm1, %xmm14
        addpd   %xmm4, %xmm10
        addpd   %xmm14, %xmm11
        movapd  %xmm3, %xmm14
        mulpd   %xmm0, %xmm3
        mulpd   %xmm1, %xmm14
        mulpd   %xmm2, %xmm0
        addpd   %xmm3, %xmm8
        mulpd   %xmm2, %xmm1
        addpd   %xmm14, %xmm9
        addpd   %xmm0, %xmm6
        addpd   %xmm1, %xmm7
        ja      .L3
.L2:
        movapd  %xmm13, (%rdi)
        movapd  %xmm12, 16(%rdi)
        movapd  %xmm11, 32(%rdi)
        movapd  %xmm10, 48(%rdi)
        movapd  %xmm9, 64(%rdi)
        movapd  %xmm8, 80(%rdi)
        movapd  %xmm7, 96(%rdi)
        movapd  %xmm6, 112(%rdi)
        ret

Версия AVX созданной сборки (-O2 -march=x86-64 -mtune=generic -mavx) по существу

fast_4:
        salq       $5, %rcx
        vmovapd    (%rdi), %ymm5
        addq       %rdx, %rcx
        vmovapd    32(%rdi), %ymm4
        cmpq       %rcx, %rdx
        vmovapd    64(%rdi), %ymm3
        vmovapd    96(%rdi), %ymm2
        jnb        .L2
.L3:
        addq       $32, %rsi
        vmovapd    -32(%rsi), %ymm1
        addq       $32, %rdx
        vmovapd    -32(%rdx), %ymm0
        cmpq       %rdx, %rcx
        vpermilpd  $0, %ymm1, %ymm6
        vperm2f128 $0, %ymm6, %ymm6, %ymm6
        vmulpd     %ymm0, %ymm6, %ymm6
        vaddpd     %ymm6, %ymm5, %ymm5
        vpermilpd  $15, %ymm1, %ymm6
        vperm2f128 $0, %ymm6, %ymm6, %ymm6
        vmulpd     %ymm0, %ymm6, %ymm6
        vaddpd     %ymm6, %ymm4, %ymm4
        vpermilpd  $0, %ymm1, %ymm6
        vpermilpd  $15, %ymm1, %ymm1
        vperm2f128 $17, %ymm6, %ymm6, %ymm6
        vperm2f128 $17, %ymm1, %ymm1, %ymm1
        vmulpd     %ymm0, %ymm6, %ymm6
        vmulpd     %ymm0, %ymm1, %ymm0
        vaddpd     %ymm6, %ymm3, %ymm3
        vaddpd     %ymm0, %ymm2, %ymm2
        ja         .L3
.L2:
        vmovapd    %ymm5, (%rdi)
        vmovapd    %ymm4, 32(%rdi)
        vmovapd    %ymm3, 64(%rdi)
        vmovapd    %ymm2, 96(%rdi)
        vzeroupper
        ret

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

На процессоре Core i5-4200U (с поддержкой AVX2) быстрые версии вышеуказанных функций вычисляют произведение двух матриц 4 × 256 за 1843 цикла ЦП (медиана) для SSE3 и 1248 циклов для AVX2. Это составляет 1,8 и 1,22 цикла на запись в матрице. Для сравнения, невекторизованная медленная версия занимает около 11 циклов на запись в матрице.

(Счетчики циклов являются средними значениями, то есть половина тестов была быстрее. Я провел лишь приблизительный тест с ~ 100 тыс. Повторений или около того, так что относитесь к этим числам с недоверием.)

На этом процессоре эффекты кеширования таковы, что AVX2 при размере матрицы 4 × 512 по-прежнему составляет 1,2 цикла на запись, но при 4 × 1024 он падает до 1,4, при 4 × 4096 до 1,5, при 4 × 8192 до 1,8 и при 4 × 65536 до 2,2 цикла на запись. Версия SSE3 остается на 1,8 цикла на запись до 4 × 3072, после чего она начинает замедляться; при 4 × 65536 это тоже примерно 2,2 цикла на вход. Я действительно считаю, что этот (ноутбук!) CPU имеет ограниченную пропускную способность кэша на данный момент.

person Nominal Animal    schedule 20.03.2016
comment
@AlphaBetaGamma:: D Подход немного отличается, он основан на векторных типах GCC, а не на стандартных встроенных функциях Intel (которые также хорошо поддерживаются другими компиляторами C). - person Nominal Animal; 20.03.2016
comment
@AlphaBetaGamma: Нет необходимости; Я очень рад, если это будет полезно и информативно. - person Nominal Animal; 20.03.2016