Почему генератор кода gcc для эпилога развернутого цикла выглядит чрезмерно сложным?

Спасибо за все комментарии. Мне жаль, что я использовал плохой пример в своем первоначальном вопросе, о котором почти все сказали бы: "О, вы должны использовать memcopy!" Но мой вопрос не об этом.

Мой вопрос более общий о том, как следует выполнять развертывание цикла вручную. На этот раз рассмотрим этот пример, суммируя все элементы массива:

#include <stdlib.h>

double sum (size_t n, double *x) {
  size_t nr = n & 1;
  double *end = x + (n - nr);
  double sum_x = 0.0;
  for (; x < end; x++) sum_x += *x;
  if (nr) sum_x += *x;
  return sum_x;
  }

Сгенерированная компилятором сборка допускает аналогичное поведение (что показано в примере копирования массива в моем исходном вопросе)

sum:
  movq %rdi, %rcx
  andl $1, %ecx
  subq %rcx, %rdi
  leaq (%rsi,%rdi,8), %rdx
  cmpq %rdx, %rsi
  jnb .L5
  movq %rsi, %rax
  pxor %xmm0, %xmm0
.L3:
  addsd (%rax), %xmm0
  addq $8, %rax
  cmpq %rax, %rdx
  ja .L3
  movq %rsi, %rax
  notq %rax
  addq %rax, %rdx
  shrq $3, %rdx
  leaq 8(%rsi,%rdx,8), %rsi
.L2:
  testq %rcx, %rcx
  je .L1
  addsd (%rsi), %xmm0
.L1:
  ret
.L5:
  pxor %xmm0, %xmm0
  jmp .L2

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

#include <stdlib.h>

double sum (size_t n, double *x) {
  size_t nr = n & 1;
  double *end = x + n;
  double sum_x = 0.0;
  if (nr) sum_x += *x;
  for (x += nr; x < end; x++) sum_x += *x;
  return sum_x;
  }

sum:
  leaq (%rsi,%rdi,8), %rdx
  pxor %xmm0, %xmm0
  andl $1, %edi
  je .L2
  addsd (%rsi), %xmm0
.L2:
  leaq (%rsi,%rdi,8), %rax
  cmpq %rax, %rdx
  jbe .L1
.L4:
  addsd (%rax), %xmm0
  addq $8, %rax
  cmpq %rax, %rdx
  ja .L4
.L1:
  ret

Я использовал только флаг компилятора -O2. Итак, как сказал Питер, сгенерированная компилятором сборка должна быть близка к исходному коду C. Тогда возникает вопрос, почему в последнем случае лучше работает компилятор?

Это не совсем вопрос, связанный с производительностью. Это просто то, что я неосознанно обнаружил (и не могу объяснить), проверяя вывод сборки компилятора для кода C из проекта C, который я писал. Спасибо еще раз. Благодарим Питера за предложение лучшего названия для вопроса.


Исходный вопрос:

Следующая небольшая функция C копирует a, вектор из n элементов, в b. Применяется ручная размотка петли глубины 2.

#include <stddef.h>

void foo (ptrdiff_t n, double *a, double *b) {
  ptrdiff_t i = 0;
  ptrdiff_t nr = n & 1;
  n -= nr;                  // `n` is an even integer
  while (i < n) {
    b[i] = a[i];
    b[i + 1] = a[i + 1];
    i += 2;
    }                       // `i = n` when the loop ends
  if (nr) b[i] = a[i];
  }

Даёт сборку х64 под gcc -O2 (любая gcc версии 5.4+). Тем не менее, я нахожу ту часть вывода, которая прокомментирована, странной. Почему компилятор вообще их генерирует?

foo:
  movq %rdi, %rcx
  xorl %eax, %eax
  andl $1, %ecx
  subq %rcx, %rdi
  testq %rdi, %rdi
  jle .L11
.L12:
  movsd (%rsi,%rax,8), %xmm0
  movsd %xmm0, (%rdx,%rax,8)
  movsd 8(%rsi,%rax,8), %xmm0
  movsd %xmm0, 8(%rdx,%rax,8)
  addq $2, %rax
  cmpq %rax, %rdi           // `i` in %rax, `n` in %rdi
  jg .L12                   // the loop ends, with `i = n`, BELOW IS WEIRD
  subq $1, %rdi             // n = n - 1;
  shrq %rdi                 // n = n / 2;
  leaq 2(%rdi,%rdi), %rax   // i = 2 * n + 2;  (this is just `i = n`, isn't it?)
.L11:
  testq %rcx, %rcx
  je .L10
  movsd (%rsi,%rax,8), %xmm0
  movsd %xmm0, (%rdx,%rax,8)
.L10:
  ret

Аналогичная версия с использованием size_t вместо ptrdiff_t дает нечто похожее:

#include <stdlib.h>

void bar (size_t n, double *a, double *b) {
  size_t i = 0;
  size_t nr = n & 1;
  n -= nr;                  // `n` is an even integer
  while (i < n) {
    b[i] = a[i];
    b[i + 1] = a[i + 1];
    i += 2;
    }                       // `i = n` when the loop ends
  if (nr) b[i] = a[i];
  }

bar:
  movq %rdi, %rcx
  andl $1, %ecx
  subq %rcx, %rdi
  je .L20
  xorl %eax, %eax
.L21:
  movsd (%rsi,%rax,8), %xmm0
  movsd %xmm0, (%rdx,%rax,8)
  movsd 8(%rsi,%rax,8), %xmm0
  movsd %xmm0, 8(%rdx,%rax,8)
  addq $2, %rax
  cmpq %rax, %rdi           // `i` in %rax, `n` in %rdi
  ja .L21                   // the loop ends, with `i = n`, BUT BELOW IS WEIRD
  subq $1, %rdi             // n = n - 1;
  andq $-2, %rdi            // n = n & (-2);
  addq $2, %rdi             // n = n + 2;  (this is just `i = n`, isn't it?)
.L20:
  testq %rcx, %rcx
  je .L19
  movsd (%rsi,%rdi,8), %xmm0
  movsd %xmm0, (%rdx,%rdi,8)
.L19:
  ret

А вот и другая эквивалентность,

#include <stdlib.h>

void baz (size_t n, double *a, double *b) {
  size_t nr = n & 1;
  n -= nr;
  double *b_end = b + n;
  while (b < b_end) {
    b[0] = a[0];
    b[1] = a[1];
    a += 2;
    b += 2;
    }                       // `b = b_end` when the loop ends
  if (nr) b[0] = a[0];
  }

но следующая сборка выглядит более странно (хотя и произведена под -O2). Теперь n, a и b все копируются, и когда цикл заканчивается, мы берем 5 строк кода только для того, чтобы получить b_copy = 0?!

baz:                        // initially, `n` in %rdi, `a` in %rsi, `b` in %rdx
  movq %rdi, %r8            // n_copy = n;
  andl $1, %r8d             // nr = n_copy & 1;
  subq %r8, %rdi            // n_copy -= nr;
  leaq (%rdx,%rdi,8), %rdi  // b_end = b + n;
  cmpq %rdi, %rdx           // if (b >= b_end) jump to .L31
  jnb .L31
  movq %rdx, %rax           // b_copy = b;
  movq %rsi, %rcx           // a_copy = a;
.L32:
  movsd (%rcx), %xmm0
  addq $16, %rax
  addq $16, %rcx
  movsd %xmm0, -16(%rax)
  movsd -8(%rcx), %xmm0
  movsd %xmm0, -8(%rax)
  cmpq %rax, %rdi           // `b_copy` in %rax, `b_end` in %rdi
  ja .L32                   // the loop ends, with `b_copy = b_end`
  movq %rdx, %rax           // b_copy = b;
  notq %rax                 // b_copy = ~b_copy;
  addq %rax, %rdi           // b_end = b_end + b_copy;
  andq $-16, %rdi           // b_end = b_end & (-16);
  leaq 16(%rdi), %rax       // b_copy = b_end + 16;
  addq %rax, %rsi           // a += b_copy;   (isn't `b_copy` just 0?)
  addq %rax, %rdx           // b += b_copy;
.L31:
  testq %r8, %r8            // if (nr == 0) jump to .L30
  je .L30
  movsd (%rsi), %xmm0       // xmm0 = a[0];
  movsd %xmm0, (%rdx)       // b[0] = xmm0;
.L30:
  ret

Кто-нибудь может объяснить, что имеет в виду компилятор во всех трех случаях?


person Zheyuan Li    schedule 04.07.2018    source источник
comment
Ожидаете ли вы, что сможете понять ассемблерный код, созданный оптимизирующим компилятором? Давным-давно я отказался от этого как от бесполезного.   -  person Yunnosch    schedule 04.07.2018
comment
вы склонны видеть странные вещи, происходящие с оптимизацией компилятора, потому что оптимизация компилятора делает странные вещи для оптимизации кода. Эти вещи могут включать развертывание циклов, упорядочивание вещей для лучшего кэширования и т. д.   -  person Nova Ardent    schedule 04.07.2018
comment
@NovaArdent: но здесь ничего из этого не происходит; это не автоматическая векторизация или развертывание (поскольку -O2 не включает -ftree-vectorize или -funroll-loops, а детектор шаблонов memcpy не может работать, потому что они не использовали restrict), так что то, что делает asm, довольно близко к исходному коду C. Вопрос не в инструкциях по планированию, а в том, почему n -= (n&1) компилируется именно так.   -  person Peter Cordes    schedule 04.07.2018
comment
@PeterCordes Я никогда не говорил, что эти конкретные вещи происходили в данном случае. IIRC, хотя -gX в gcc включит некоторые из этих флагов, не так ли?   -  person Nova Ardent    schedule 04.07.2018
comment
@NovaArdent: нет, параметры -g не влияют на генерацию кода, только метаданные отладки.   -  person Peter Cordes    schedule 04.07.2018
comment
@PeterCordes Хорошо, этот сайт посвящен обучению, поэтому я оставлю здесь свои комментарии, чтобы ваши имели смысл, и поэтому небольшой разговор может стать обучающим моментом.   -  person Nova Ardent    schedule 04.07.2018
comment
@NovaArdent: да, все в порядке. Меня просто раздражает, когда люди задают интересные подробные вопросы о компиляторе/производительности, и первые ответы - это слишком сложно для смертных, и ваш комментарий тоже был в том же духе. (В вопросе можно было бы использовать более подробное изложение того, что делают рассматриваемые инструкции, и ссылку на Godbolt, т. е. хорошим заголовком могло бы быть, почему генератор кода gcc для моего эпилога развернутого цикла выглядит чрезмерно сложным?)   -  person Peter Cordes    schedule 04.07.2018
comment
Теперь с обновленным вопросом ответ находится в первом случае, значение nr должно быть запомнено до завершения цикла, и выполняется дополнительный расчет. Во втором случае nr избавляется от nr навсегда как можно скорее (и без вычисления хвоста), что позволяет анализировать менее сложную ситуацию, что, вероятно, улучшает сборку.   -  person Gunther Schulz    schedule 04.07.2018


Ответы (2)


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

#include <stdlib.h>
#include <stddef.h>

void foo (ptrdiff_t n, double *a, double *b) {
  ptrdiff_t i = n & 1;
  if (i) b[0] = a[0];
  while (i < n) {
    b[i] = a[i];
    b[i + 1] = a[i + 1];
    i += 2;
    }
  }

void bar (size_t n, double *a, double *b) {
  size_t i = n & 1;
  if (i) b[0] = a[0];
  while (i < n) {
    b[i] = a[i];
    b[i + 1] = a[i + 1];
    i += 2;
    }
  }

void baz (size_t n, double *a, double *b) {
  size_t nr = n & 1;
  double *b_end = b + n;
  if (nr) b[0] = a[0];
  b += nr;
  while (b < b_end) {
    b[0] = a[0];
    b[1] = a[1];
    a += 2;
    b += 2;
    }
  }

foo:
  movq %rdi, %rax
  andl $1, %eax
  je .L9
  movsd (%rsi), %xmm0
  movsd %xmm0, (%rdx)
  cmpq %rax, %rdi
  jle .L11
.L4:
  movsd (%rsi,%rax,8), %xmm0
  movsd %xmm0, (%rdx,%rax,8)
  movsd 8(%rsi,%rax,8), %xmm0
  movsd %xmm0, 8(%rdx,%rax,8)
  addq $2, %rax
.L9:
  cmpq %rax, %rdi
  jg .L4
.L11:
  ret

bar:
  movq %rdi, %rax
  andl $1, %eax
  je .L20
  movsd (%rsi), %xmm0
  movsd %xmm0, (%rdx)
  cmpq %rax, %rdi
  jbe .L21
.L15:
  movsd (%rsi,%rax,8), %xmm0
  movsd %xmm0, (%rdx,%rax,8)
  movsd 8(%rsi,%rax,8), %xmm0
  movsd %xmm0, 8(%rdx,%rax,8)
  addq $2, %rax
.L20:
  cmpq %rax, %rdi
  ja .L15
.L21:
  ret

baz:
  leaq (%rdx,%rdi,8), %rcx
  andl $1, %edi
  je .L23
  movsd (%rsi), %xmm0
  movsd %xmm0, (%rdx)
.L23:
  leaq (%rdx,%rdi,8), %rax
  cmpq %rax, %rcx
  jbe .L22
.L25:
  movsd (%rsi), %xmm0
  addq $16, %rax
  addq $16, %rsi
  movsd %xmm0, -16(%rax)
  movsd -8(%rsi), %xmm0
  movsd %xmm0, -8(%rax)
  cmpq %rax, %rcx
  ja .L25
.L22:
  ret
person Zheyuan Li    schedule 04.07.2018
comment
Это в основном академическое / любопытство, верно? Если вы действительно хотите, чтобы он работал быстро, вы должны использовать restrict, чтобы он мог распознать его как memcpy и встроить оптимизированную копию или вызвать библиотечную функцию. И/или скомпилировать с полной оптимизацией (-O3) вместо частичной оптимизации (-O2). O3 -march=native хорош, если вы хотите что-то настроить для компьютера, на котором он скомпилирован, используя любые версии SSE/AVX, которые он поддерживает. - person Peter Cordes; 04.07.2018
comment
Цель оптимизированного кода — не быть аккуратным, а оптимизированным. - person Jongware; 04.07.2018
comment
@PeterCordes Да, просто нужно разумное объяснение. На самом деле, такой цикл привязан к памяти, поэтому развертывание цикла и SIMD не принесут никакой пользы. - person Zheyuan Li; 04.07.2018
comment
@ usr2564301: этот код выглядит лучше оптимизированным, чем генератор кода в вопросе, если ветвь хорошо предсказывает. Если нет, то, возможно, было бы лучше провести окончательную проверку итерации после цикла, где ошибочный прогноз может разрешиться, пока куча копий все еще находится в полете. - person Peter Cordes; 04.07.2018
comment
@李哲源: в наши дни память работает быстро, особенно если какие-либо из ваших данных горячи в L3. Если это не так, и вы настраиваете это исключительно для гигантских копий, вам следует использовать хранилища NT для обхода кеша вместо запуска RFO для вывода. Копирование 8-байтовыми фрагментами также излишне недружественно для гиперпоточности, заполняя конвейер большим количеством мопов, чтобы замедлить другое логическое ядро, использующее то же физическое ядро. Меньшие фрагменты означают, что выполнение не по порядку не запускается так далеко вперед, поэтому вы не запускаете обходы страниц так далеко перед тем, где вы читаете, когда вы находитесь близко к границе страницы. - person Peter Cordes; 04.07.2018
comment
См. также Enhanced REP MOVSB ​​для memcpy, чтобы узнать больше о пропускной способности памяти x86. - person Peter Cordes; 04.07.2018
comment
Я только что попробовал ваш foo против glibc memcpy для больших копий на моем Skylake i7-6700k (кэш 8 МБ) с двухканальной памятью DDR4-2400. Для 100000000 байт (100 МБ), скопированных 1000 раз, ваша версия занимает 9,77+-0,5% секунды, а memcpy — 6,68+-0,4% секунды. (ЦП работал только на частоте ~ 3,3 ГГц для memcpy, но 3,7 ГГц для вашего, потому что я не заставлял его работать на максимальной тактовой частоте, как это было бы, если бы была реальная работа на других ядрах.) Фактический тестовый код: godbolt.org/g/DYtKTR. Как ни странно, для копий размером около 5M (помещается в кеш L3) разница намного меньше. Но для 500kB * 10k iter это 0,24 против 0,15 - person Peter Cordes; 04.07.2018
comment
Круто, да, я подумал, что это было интересно, хотя цикл, разворачиваемый в этом случае, был просто memcpy. Надеюсь, ваше редактирование прояснит это для большего числа читателей. - person Peter Cordes; 04.07.2018

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

Например, если вы знаете, что исходный массив не будет изменен во время копирования, сообщите об этом компилятору, добавив квалификатор const к указанным исходным данным.

void foo (ptrdiff_t n, double *a, double const *b)

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

void foo (ptrdiff_t n, double *restrict a, double const *restrict b)

В конечном счете, если вам нужна максимально оптимизированная копия (поставщики компиляторов тратят на это МНОГО времени), используйте memcpy для непересекающихся диапазонов и memmove для перекрывающихся диапазонов.

person Gunther Schulz    schedule 04.07.2018
comment
Он не выполняет автоматическую векторизацию, потому что OP не включил автоматическую векторизацию (-O2). Вопрос был о расчете адреса для эпилога очистки вне цикла, а не в основном цикле. Однако ваше первое предложение является ответом на вопрос для неподписанной части. - person Peter Cordes; 04.07.2018