Почему p1007r0 std :: accept_aligned устраняет необходимость в эпилоге?

мой понимание в том, что векторизация кода работает примерно так:

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

Для данных в массиве между первым адресом, кратным 128, и последним адресом, кратным 128, используйте инструкцию SIMD.

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

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

Цитата из предложения:

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

3

Решение

Вы можете увидеть влияние кода на использование GNU C / C ++ __builtin_assume_aligned.

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

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

это Ссылка на компилятор Godbolt показывает эти функции, скомпилированные для x86-64 с ICC18, gcc7.3 и clang6.0. лязг разворачивается очень агрессивно, но все еще использует неприсоединившиеся магазины. Это кажется странным способом потратить столько кода на цикл, который просто магазины.

// aligned, and size a multiple of vector width
void set42_aligned(int *p) {
p = (int*)__builtin_assume_aligned(p, 64);
for (int i=0 ; i<1024 ; i++ ) {
*p++ = 0x42;
}
}

# gcc7.3 -O3   (arch=tune=generic for x86-64 System V: p in RDI)

lea     rax, [rdi+4096]              # end pointer
movdqa  xmm0, XMMWORD PTR .LC0[rip]  # set1_epi32(0x42)
.L2:                                     # do {
add     rdi, 16
movaps  XMMWORD PTR [rdi-16], xmm0
cmp     rax, rdi
jne     .L2                          # }while(p != endp);
rep ret

Это почти то же самое, что я делал бы вручную, за исключением, может быть, разворачивания на 2, чтобы OoO exec мог обнаружить, что ветвь выхода из цикла не была занята, при этом все еще пережевывая магазины.

Таким образом, выровненная версия включает пролог а также эпилог:

// without any alignment guarantee
void set42(int *p) {
for (int i=0 ; i<1024 ; i++ ) {
*p++ = 0x42;
}
}

~26 instructions of setup, vs. 2 from the aligned version

.L8:            # then a bloated loop with 4 uops instead of 3
add     eax, 1
add     rdx, 16
movaps  XMMWORD PTR [rdx-16], xmm0
cmp     ecx, eax
ja      .L8               # end of main vector loop

# epilogue:
mov     eax, esi    # then destroy the counter we spent an extra uop on inside the loop.  /facepalm
and     eax, -4
mov     edx, eax
sub     r8d, eax
cmp     esi, eax
lea     rdx, [r9+rdx*4]   # recalc a pointer to the last element, maybe to avoid a data dependency on the pointer from the loop.
je      .L5
cmp     r8d, 1
mov     DWORD PTR [rdx], 66      # fully-unrolled final up-to-3 stores
je      .L5
cmp     r8d, 2
mov     DWORD PTR [rdx+4], 66
je      .L5
mov     DWORD PTR [rdx+8], 66
.L5:
rep ret

Даже для более сложного цикла, который выиграл бы от небольшого развертывания, gcc оставляет основной векторизованный цикл вообще не развернутым, но затрачивает кучу кода размером на полностью развернутый скалярный пролог / эпилог. Это действительно плохо для AVX2 256-битной векторизации с uint16_t элементы или что-то. (до 15 элементов в прологе / эпилоге, а не 3). Это не разумный компромисс, поэтому он помогает gcc7 и более ранним версиям значительно определить, когда указатели выровнены. (Скорость выполнения не сильно меняется, но она имеет большое значение для уменьшения раздувания кода.)


Кстати, gcc8 предпочитает использовать невыровненные загрузки / хранилища при условии, что данные часто выровнены. Современное оборудование имеет дешевые невыровненные 16- и 32-байтовые загрузки / хранилища, поэтому позволить аппаратному обеспечению справиться со стоимостью загрузок / хранилищ, которые разбиты по границе строки кэша, часто хорошо. (64-байтовые хранилища AVX512 часто стоит выравнивать, потому что любое смещение означает разделение строки кэша на каждый доступ, а не каждый другой или каждый четвертый.)

Другой фактор заключается в том, что ранее полностью развернутые скалярные прологи / эпилоги gcc являются дерьмом по сравнению с умной обработкой, когда вы делаете один невыровненный потенциально перекрывающийся вектор в начале / конце. (Посмотрите эпилог в этой рукописной версии set42). Если бы gcc знал, как это сделать, стоило бы выравнивать его чаще.

1

Другие решения

Это обсуждается в самом документе в разделе 5:

Функция, которая возвращает указатель T * и гарантирует, что он будет
указать на перегруженную память, может вернуться так:

T* get_overaligned_ptr()
{
// code...
return std::assume_aligned<N>(_data);
}

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

begin() а также end() методы — это средства доступа к данным для выровненного буфера _data, То есть, begin() возвращает указатель на первый байт буфера и end() возвращает указатель на один байт после последнего байта буфера.

Предположим, они определены следующим образом:

T* begin()
{
// code...
return std::assume_aligned<N>(_data);
}
T* end()
{
// code...
return _data + size; // No alignment hint!
}

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

T* begin()
{
// code...
return std::assume_aligned<N>(_data);
}
T* end()
{
// code...
return std::assume_aligned<N>(_data + size);
}

Тогда компилятор сможет устранить эпилог. Например, если N равно 128 битам, то каждый 128-битный фрагмент буфера гарантированно выровнен по 128 битам. Обратите внимание, что это возможно только тогда, когда размер буфера кратен выравниванию.

1