Векторизация с невыровненными буферами: использование VMASKMOVPS: создание маски из числа смещений? Или вообще не использовать этот insn

gcc 5.3 с -O3 -mavx -mtune=haswell для x86-64 делает удивительно громоздкий код для обработки потенциально смещенных входных данных для кода, такого как:

// convenient simple example of compiler input
// I'm not actually interested in this for any real program
void floatmul(float *a) {
  for (int i=0; i<1024 ; i++)
    a[i] *= 2;
}

clang использует невыровненные инструкции загрузки / сохранения, но gcc выполняет скалярное введение / вывод и выровненный векторный цикл: он снимает первые до 7 невыровненных итераций, полностью разворачивая их в последовательность

    vmovss  xmm0, DWORD PTR [rdi]
    vaddss  xmm0, xmm0, xmm0      ; multiply by two
    vmovss  DWORD PTR [rdi], xmm0
    cmp     eax, 1
    je      .L13
    vmovss  xmm0, DWORD PTR [rdi+4]
    vaddss  xmm0, xmm0, xmm0
    vmovss  DWORD PTR [rdi+4], xmm0
    cmp     eax, 2
    je      .L14
    ...

Это кажется довольно ужасным, особенно для процессоров с кэшем UOP. Я сообщил об ошибке gcc по этому поводу с предложением меньшего / лучшего кода, который gcc мог бы использовать при очистке невыровненных итераций. Это, вероятно, все еще не оптимально, хотя.

Этот вопрос о том, что на самом деле будет оптимальным с AVX. Я спрашиваю об общих решениях, которые gcc и другие компиляторы могли бы / должны использовать. (Я не нашел ни одного попадания в список рассылки gcc с обсуждением этого, но не стал долго искать.)


Вероятно, будет несколько ответов, так как то, что оптимально для -mtune=haswell вероятно будет отличаться от того, что оптимально для -mtune=bdver3 (Каток). И затем возникает вопрос о том, что оптимально при разрешении расширений набора команд (например, AVX2 для 256-битных целочисленных данных, BMI1 для преобразования счетчика в битовую маску с меньшим количеством инструкций).

Мне известно руководство по оптимизации сборки Agner Fog, раздел 13.5 Доступ к невыровненным данным и частичным векторам. Он предлагает либо использовать невыровненный доступ, либо выполнить перезаписывающую запись в начале и / или конце, либо перетасовывать данные из выровненных обращений (но PALIGNR принимает только счет imm8, поэтому 2x pshufb / por). Он скидки VMASKMOVPS как бесполезный, вероятно, из-за того, как плохо он работает на AMD. Подозреваю, что если настраивать на Intel, стоит задуматься. Не очевидно, как создать правильную маску, отсюда и название вопроса.


Может оказаться, что лучше просто использовать невыровненный доступ, как это делает clang. Для коротких буферов накладные расходы на выравнивание могут убить любую выгоду от избежания расщепления строки кэша для основного цикла. Для больших буферов основная память или L3 в качестве узкого места могут скрывать штраф за расщепление кеш-линии. Если у кого-то есть экспериментальные данные, подтверждающие это для любого реального кода, который они настроили, это тоже полезная информация.


VMASKMOVPS выглядит полезным для целей Intel. (Версия SSE ужасна, с неявной не временной подсказкой, но в версии AVX этого нет. Есть даже новая функция, гарантирующая, что вы не получите версию SSE для операндов 128b: _mm128_maskstore_ps) AVX-версия на Haswell работает немного медленнее:

  • 3 мопа / задержка 4c / пропускная способность 1 на 2c в качестве нагрузки.
  • Пропускная способность 4 мегапикселей / задержка 14 с / пропускная способность 1 на 2 с в виде хранилища 256 бит.
  • Пропускная способность 4 моп / 13 с / пропускная способность 1 на 1 с в виде хранилища 128 байт.

Форма хранения все еще необычайно медленная на процессорах AMD, как Jaguar (1 на 22c tput), так и на семейство Bulldozer: 1 на 16c на Steamroller (аналогично Bulldozer) или 1 на ~180c на Piledriver.

Но если мы хотим использовать VMASKMOVPSнам нужен вектор с старшим битом, установленным в каждом элементе, который на самом деле должен быть загружен / сохранен. PALIGNR и PSRLDQ (для использования с вектором из всех единиц) принимают только значения постоянной времени компиляции.

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

2 ответа

Решение

Только для AVX: не выровненный доступ в начале / конце, конвейерная загрузка, чтобы избежать проблем при перезаписи на месте.

Спасибо @StephenCanon за указание, что это лучше, чем VMASKMOVPS за все, что VMASKMOVPS мог бы помочь с зацикливанием невыровненных буферов.

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

section .text
global floatmul   ; (float *rdi)
floatmul:

    lea    rdx, [rdi + 4096 - 32]  ; one full vector less than the end address (calculated *before* masking for alignment).
                ;;  replace 4096 with rsi*4 if rsi has the count (in floats, not bytes)

    vmovups  ymm0, [rdi]
    vaddps   ymm0, ymm0, ymm0   ; *= 2.0
    ; don't store yet

    lea    rax, [rdi+32]
    and    rax, ~0x1c           ; 0x1c = 7 << 2 = 0b11100
    vmovups  ymm1, [rax]        ; first aligned vector, for use by first loop iteration

    vmovups  [rdi], ymm0        ; store the first unaligned vector
    vmovups  ymm0, [rdx]        ; load the *last* unaligned vector

.loop:
      ;; on entry: [rax] is already loaded into ymm1
    vaddps   ymm1, ymm1, ymm1   ; *= 2.0
    vmovups  [rax]              ; vmovaps would fault if p%4 != 0
    add      rax, 32
    vmovups  ymm1, [rax]
    cmp      rax, rdx           ; while( (p+=8) < (endp-8) );
    jb  .loop

    ; discard ymm1.  It includes data from beyond the end of the array (aligned case: same as ymm0)

    vaddss   ymm0, ymm0, ymm0   ; the last 32B, which we loaded before the loop
    vmovups  [rdx], ymm0
    ret

    ;   End alignment:
    ; a[] = XXXX XXXX ABCD E___    _ = garbage past the end
    ;                  ^rdx
    ;       ^rax ^rax ^rax ^rax(loop exit)

    ;   ymm0 = BCDE
    ;   ymm1 loops over ..., XXXX, ABCD, E___
    ;   The last load off the end of the array includes garbage
    ;     because we pipeline the load for the next iteration

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

Накладные расходы:

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

  • 2 дополнительные копии тела цикла (загрузка / вычисление / сохранение). (Первая и последняя итерация очищены).


Компиляторы, вероятно, не будут рады создавать подобный код при автоматической векторизации. Valgrind будет сообщать о доступах за пределами границ массива, и делает это с помощью пошаговых инструкций и инструкций по декодированию, чтобы увидеть, к чему они обращаются. Так что простого нахождения на той же странице (и в строке кэша), что и последний элемент в массиве, недостаточно. Также обратите внимание, что если входной указатель не выровнен по 4B, мы можем потенциально прочитать на другую страницу и выполнить segfault.

Чтобы Valgrind был доволен, мы могли бы остановить цикл на две ширины вектора раньше, чтобы выполнить загрузку специального случая для невыровненной последней ширины вектора массива. Это потребовало бы дублирования тела цикла в дополнительное время (незначительно в этом примере, но это тривиально нарочно.) Или, возможно, избегайте конвейерной обработки, заставляя код вступления переходить в середину цикла. (Это может быть неоптимальным для uop-кэша, хотя: (части) тела цикла могут оказаться в кэше uop дважды.)

TODO: напишите версию, которая переходит в цикл в середине пути.

Загрузите маску для VMOVMASKPS из окна в таблицу. AVX2 или AVX1 с несколькими дополнительными инструкциями или таблицей большего размера.

Маска также может быть использована для ANDPS в регистрах в сокращении, которое необходимо считать каждый элемент ровно один раз. Как отмечает Стивен Канон в комментариях к OP, конвейерная загрузка может позволить перекрывающимся невыровненным хранилищам работать даже для функции перезаписи на месте, как в примере, который я выбрал, поэтому VMASKMOVPS НЕ лучший выбор здесь.


Это должно быть хорошо на процессорах Intel, особенно. Haswell и позже для AVX2.

Метод Агнера Фога для получения маски pshufb на самом деле дал очень эффективную идею: выполнить не выровненную загрузку, извлекая окно данных из таблицы. Вместо гигантской таблицы масок используйте индекс как способ сдвига байтов данных в памяти.


Маски в порядке байтов LSB-first (так как они хранятся в памяти), а не обычные обозначения для {X3,X2,X1,X0} элементы в векторе. Как написано, они выровнены с выровненным окном, включая начало / конец входного массива в памяти.

  • отсчет начального смещения = 0: маска = все единицы (выровненный регистр)
  • отсчет начала смещения = 1: маска = {0,-1,-1,-1,-1,-1,-1,-1} (пропустить один в первом 32B)
  • ...
  • отсчет начала смещения = 7: маска = {0, 0, 0, 0, 0, 0, 0,-1} (пропустить все, кроме одного в первом 32B)

  • end misalign count = 0: нет конечных элементов. маска = все единицы (выровненный регистр).
    это странный случай, не похожий на count = 1. Пара дополнительных инструкций для этого особого случая стоит избегать дополнительной итерации цикла и очистки с помощью маски из всех нулей.

  • end misalign count = 1: один конечный элемент. маска = {-1, 0, 0, 0, 0, 0, 0, 0}
  • ...
  • число смещений конца = 7: семь конечных элементов. маска = {-1,-1,-1,-1,-1,-1,-1, 0}

Непроверенный код, предположим, что есть ошибки

section .data
align 32  ; preferably no cache-line boundaries inside the table

; byte elements, to be loaded with pmovsx. all-ones sign-extends
    DB  0,  0,  0,  0,  0,  0,  0,  0
masktable_intro:                      ; index with 0..-7
    DB -1, -1, -1, -1, -1, -1, -1, -1
masktable_outro:                      ; index with -8(aligned), or -1..-7
    DB  0,  0,  0,  0,  0,  0,  0,  0

; the very first and last 0 bytes are not needed, since we avoid an all-zero mask.


section .text
global floatmul   ; (float *rdi)
floatmul:
    mov    eax, edi
    and    eax, 0x1c            ; 0x1c = 7 << 2 = 0b11100

    lea    rdx, [rdi + 4096 - 32]  ; one full vector less than the end address (calculated *before* masking for alignment).
                ;;  replace 4096 with rsi*4 if rsi has the count (in floats, not bytes)

    and    rdi, ~0x1c           ; Leave the low 2 bits alone, so this still works on misaligned floats.

    shr    eax, 2               ; misalignment-count, in the range [0..7]

    neg        rax
    vpmovsxbd  ymm0, [masktable_intro + rax]  ; Won't link on OS X: Need a separate LEA for RIP-relative

    vmaskmovps ymm1, ymm0, [rdi]
    vaddps     ymm1, ymm1, ymm1   ; *= 2.0
    vmaskmovps [rdi], ymm0, ymm1

    ;;; also prepare the cleanup mask while the table is still hot in L1 cache

    ; if the loop count known to be a multiple of the vector width,
    ; the alignment of the end will be the same as the alignment of the start
    ; so we could just invert the mask
    ; vpxor    xmm1, xmm1, xmm1     ; doesn't need an execution unit
    ; vpcmpeqd ymm0, ymm1, ymm0

    ; In the more general case: just re-generate the mask from the one-past-the-end addr
    mov    eax, edx
    xor    ecx, ecx      ; prep for setcc
    and    eax, 0x1c     ; sets ZF when aligned
    setz   cl            ; rcx=1 in the aligned special-case, else 0
    shr    eax, 2
    lea    eax, [rax + rcx*8]   ; 1..7, or 8 in the aligned case
    neg    rax
    vpmovsxbd  ymm0, [masktable_outro + rax]


.loop:
    add      rdi, 32
    vmovups  ymm1, [rdi]    ; Or vmovaps if you want to fault if the address isn't 4B-aligned
    vaddps   ymm1, ymm1, ymm1   ; *= 2.0
    vmovups  [rdi], ymm1
    cmp      rdi, rdx           ; while( (p+=8) < (start+1024-8) )
    jb    .loop        ; 5 fused-domain uops, yuck.

    ; use the outro mask that we generated before the loop for insn scheduling / cache locality reasons.

    vmaskmov ymm1, ymm0, [rdi]
    vaddps     ymm1, ymm1, ymm1   ; *= 2.0
    vmaskmovps [rdi], ymm0, ymm1
    ret


    ; vpcmpeqd ymm1, ymm1, ymm1   ; worse way to invert the mask: dep-chain breaker but still needs an execution unit to make all-ones instead of all-zeros.
    ; vpxor    ymm0, ymm0, ymm1

Для этого требуется загрузка из таблицы, которая может отсутствовать в кеше L1, и 15B данных таблицы. (Или 24B, если число циклов также является переменным, и мы должны генерировать конечную маску отдельно).

В любом случае, после 4 инструкций для генерации счетчика смещений и выровненного начального адреса, для получения маски требуется только одна инструкция vpmosvsxbd. (Форма ymm, mem не может микроплавиться, так что это 2 мопа). Это требует AVX2.


Без AVX2:

  • 2x vpmovsxbd на два регистра 128b ([masktable_intro + rax] а также [masktable_intro + rax + 4])
  • vinsertf128

Или: (больше insns и больше давления в случайном порядке, но меньше давления в порту нагрузки)

  • vpmovsxbw в регистр 128b
  • vpunpcklwd / vpunpckhwd в два регистра xmm (src1=src2 для обоих)
  • vinsertf128

Или же:

  • vmovdqu из таблицы DWORD 60B (DD) вместо байтов (DB). Это на самом деле сохранит insn относительно AVX2: address & 0x1c это индекс, без необходимости сдвига вправо на два. Вся таблица по-прежнему помещается в строку кэша, но без места для других констант, которые может использовать алгоритм.

Накладные расходы:

  • Целочисленные операции: 5 моп в начале, чтобы получить индекс и выровнять стартовый указатель. 7 моп, чтобы получить индекс для конечной маски. Всего 12 мопов в регистре GP, кроме простого использования unaligned, если число элементов цикла кратно векторной ширине.

  • AVX2: Два векторных insn с 2-fused-domain-uop для перехода от индекса [0..7] в регистре GP к маске в рег. YMM. (Один для начальной маски, один для конечной маски). Использует таблицу 24B, доступ к которой осуществляется в окне 8B с гранулярностью байтов.

  • AVX: шесть векторных insns 1-fused-domain-uop (три в начале, три в конце). С RIP-относительной адресацией для таблицы четыре из этих инструкций будут [base+index] и не будет микроплавкого предохранителя, так что дополнительные два целочисленных insns могут быть лучше.

Код внутри цикла дублируется 3 раза.


TODO: написать другой ответ, генерирующий маску на лету, возможно, в виде байтов в регистре 64b, затем распаковать его в 256b. Может быть, с бит-сдвигом или BZHI BMI2 (-1, количество)?

Другие вопросы по тегам