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

Мое понимание заключается в том, что векторизация кода работает примерно так:

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

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

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

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

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

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

Ответы [ 2 ]

0 голосов
/ 18 мая 2018

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

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

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

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

// 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-байтовые загрузки / хранилища, поэтому позволить аппаратному обеспечению справиться со стоимостью загрузок / хранилищ, которые разбиты по границе строки кэша, часто хорошо.(AVX512 64-байтовые хранилища часто стоят того, чтобы их выровнять, потому что любое смещение означает разделение строки кэша на каждый доступ, а не на каждый другой или каждый 4-й.)

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

0 голосов
/ 18 мая 2018

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

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

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

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

The *Методы 1010 * и 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 битам.Обратите внимание, что это возможно только тогда, когда размер буфера кратен выравниванию.

...